From f3d8feedb554a114fa4960a429691ba3537cf267 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 11:47:53 +0000 Subject: [PATCH 01/15] add plugin hooks for source resolution Co-authored-by: Aiden Bai --- .../react-grab/src/core/plugin-registry.ts | 38 +++++++++++++++++++ packages/react-grab/src/types.ts | 21 ++++++++++ 2 files changed, 59 insertions(+) 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/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, From 349c21e7b0ec5e5914cea09fc2a5eb3eec687ca9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 11:53:57 +0000 Subject: [PATCH 02/15] add solid vue svelte source resolver utilities Co-authored-by: Aiden Bai --- .../src/utils/get-framework-source-info.ts | 80 +++++++ .../src/utils/get-solid-source-info.ts | 217 ++++++++++++++++++ .../src/utils/get-svelte-source-info.ts | 69 ++++++ .../src/utils/get-vue-source-info.ts | 99 ++++++++ .../src/utils/parse-source-location.ts | 46 ++++ 5 files changed, 511 insertions(+) create mode 100644 packages/react-grab/src/utils/get-framework-source-info.ts create mode 100644 packages/react-grab/src/utils/get-solid-source-info.ts create mode 100644 packages/react-grab/src/utils/get-svelte-source-info.ts create mode 100644 packages/react-grab/src/utils/get-vue-source-info.ts create mode 100644 packages/react-grab/src/utils/parse-source-location.ts diff --git a/packages/react-grab/src/utils/get-framework-source-info.ts b/packages/react-grab/src/utils/get-framework-source-info.ts new file mode 100644 index 000000000..25d560839 --- /dev/null +++ b/packages/react-grab/src/utils/get-framework-source-info.ts @@ -0,0 +1,80 @@ +import type { + ElementSourceInfo, + ElementStackContextOptions, + SourceInfo, +} from "../types.js"; +import { getSolidSourceInfo } from "./get-solid-source-info.js"; +import { getSvelteSourceInfo } from "./get-svelte-source-info.js"; +import { getVueSourceInfo } from "./get-vue-source-info.js"; + +const getResolvedFrameworkSourceInfo = ( + element: Element, +): ElementSourceInfo | null => { + const resolvers = [ + getSvelteSourceInfo, + getVueSourceInfo, + getSolidSourceInfo, + ] as const; + + for (const resolveSourceInfo of resolvers) { + const sourceInfo = resolveSourceInfo(element); + if (!sourceInfo) continue; + if (!sourceInfo.filePath) continue; + return sourceInfo; + } + + return null; +}; + +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 getFrameworkSourceInfo = ( + element: Element, +): ElementSourceInfo | null => { + return getResolvedFrameworkSourceInfo(element); +}; + +export const getFrameworkSourceInfoForApi = ( + element: Element, +): SourceInfo | null => { + const sourceInfo = getResolvedFrameworkSourceInfo(element); + if (!sourceInfo) return null; + return { + filePath: sourceInfo.filePath, + lineNumber: sourceInfo.lineNumber, + componentName: sourceInfo.componentName, + }; +}; + +export const getFrameworkComponentName = (element: Element): string | null => { + const sourceInfo = getResolvedFrameworkSourceInfo(element); + if (!sourceInfo) return null; + return sourceInfo.componentName; +}; + +export const getFrameworkStackContext = ( + element: Element, + options: ElementStackContextOptions = {}, +): string => { + const { maxLines = 3 } = options; + if (maxLines < 1) return ""; + + const sourceInfo = getResolvedFrameworkSourceInfo(element); + if (!sourceInfo) return ""; + + const sourceLocation = formatSourceLocation(sourceInfo); + if (sourceInfo.componentName) { + return `\n in ${sourceInfo.componentName} (at ${sourceLocation})`; + } + + return `\n in ${sourceLocation}`; +}; diff --git a/packages/react-grab/src/utils/get-solid-source-info.ts b/packages/react-grab/src/utils/get-solid-source-info.ts new file mode 100644 index 000000000..6804860c0 --- /dev/null +++ b/packages/react-grab/src/utils/get-solid-source-info.ts @@ -0,0 +1,217 @@ +import type { ElementSourceInfo } from "../types.js"; +import { parseSourceLocation } from "./parse-source-location.js"; + +interface LocatorExpressionStart { + lineNumber: number; + columnNumber: number; +} + +const LOCATOR_ID_SEPARATOR = "::"; +const LOCATOR_PATH_ATTRIBUTE_NAME = "data-locatorjs"; +const LOCATOR_ID_ATTRIBUTE_NAME = "data-locatorjs-id"; +const SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME = "data-source-loc"; +const SOLID_DEVTOOLS_PROJECT_PATH_GLOBAL_NAME = "$sdt_projectPath"; +const LOCATOR_DATA_GLOBAL_NAME = "__LOCATOR_DATA__"; +const ABSOLUTE_UNIX_PREFIX = "/"; +const RELATIVE_CURRENT_PREFIX = "./"; +const ABSOLUTE_WINDOWS_PATTERN = /^[a-zA-Z]:\\/; +const LOCATOR_ATTRIBUTE_SELECTOR = `[${LOCATOR_PATH_ATTRIBUTE_NAME}], [${LOCATOR_ID_ATTRIBUTE_NAME}], [${SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME}]`; + +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 readArray = (value: unknown): unknown[] => + Array.isArray(value) ? value : []; + +const isAbsoluteFilePath = (filePath: string): boolean => + filePath.startsWith(ABSOLUTE_UNIX_PREFIX) || + ABSOLUTE_WINDOWS_PATTERN.test(filePath); + +const normalizeSolidDevtoolsFilePath = (filePath: string): string => { + if (typeof window === "undefined") return filePath; + if (isAbsoluteFilePath(filePath)) return filePath; + + const projectPath = readString( + Reflect.get(window, SOLID_DEVTOOLS_PROJECT_PATH_GLOBAL_NAME), + ); + if (!projectPath) return filePath; + + const normalizedProjectPath = projectPath.endsWith(ABSOLUTE_UNIX_PREFIX) + ? projectPath.slice(0, -1) + : projectPath; + const normalizedFilePath = filePath.startsWith(RELATIVE_CURRENT_PREFIX) + ? filePath.slice(2) + : filePath; + + return `${normalizedProjectPath}/${normalizedFilePath}`; +}; + +const getLocatorDataRecord = (): Record | null => { + if (typeof window === "undefined") return null; + const locatorData = Reflect.get(window, LOCATOR_DATA_GLOBAL_NAME); + return isRecord(locatorData) ? locatorData : null; +}; + +const getLocatorFileData = ( + filePath: string, +): Record | null => { + const locatorData = getLocatorDataRecord(); + if (!locatorData) return null; + const fileData = locatorData[filePath]; + return isRecord(fileData) ? fileData : null; +}; + +const getExpressionStart = ( + expression: unknown, +): LocatorExpressionStart | null => { + if (!isRecord(expression)) return null; + const location = expression.loc; + if (!isRecord(location)) return null; + const start = location.start; + if (!isRecord(start)) return null; + const lineNumber = readNumber(start.line); + const columnNumber = readNumber(start.column); + if (lineNumber === null || columnNumber === null) return null; + return { lineNumber, columnNumber }; +}; + +const getWrappingComponentName = ( + fileData: Record, + expression: unknown, +): string | null => { + if (!isRecord(expression)) return null; + const wrappingComponentId = readNumber(expression.wrappingComponentId); + if (wrappingComponentId === null) return null; + if (!Number.isInteger(wrappingComponentId)) return null; + const componentList = readArray(fileData.components); + const component = componentList[wrappingComponentId]; + if (!isRecord(component)) return null; + return readString(component.name); +}; + +const findExpressionByLocation = ( + expressionList: unknown[], + lineNumber: number, + columnNumber: number, +): unknown | null => { + for (const expression of expressionList) { + const start = getExpressionStart(expression); + if (!start) continue; + if (start.lineNumber === lineNumber && start.columnNumber === columnNumber) { + return expression; + } + } + return null; +}; + +const parseLocatorId = ( + locatorId: string, +): { filePath: string; expressionId: number } | null => { + const separatorIndex = locatorId.lastIndexOf(LOCATOR_ID_SEPARATOR); + if (separatorIndex === -1) return null; + + const filePath = locatorId.slice(0, separatorIndex); + const rawExpressionId = locatorId.slice( + separatorIndex + LOCATOR_ID_SEPARATOR.length, + ); + const expressionId = Number.parseInt(rawExpressionId, 10); + + if (!filePath) return null; + if (Number.isNaN(expressionId)) return null; + if (expressionId < 0) return null; + + return { filePath, expressionId }; +}; + +const resolveFromLocatorPath = ( + locatorPath: string, +): ElementSourceInfo | null => { + const parsedLocation = parseSourceLocation(locatorPath); + if (!parsedLocation) return null; + + const fileData = getLocatorFileData(parsedLocation.filePath); + const expressionList = fileData ? readArray(fileData.expressions) : []; + const matchedExpression = findExpressionByLocation( + expressionList, + parsedLocation.lineNumber, + parsedLocation.columnNumber, + ); + const componentName = + fileData && matchedExpression + ? getWrappingComponentName(fileData, matchedExpression) + : null; + + return { + filePath: parsedLocation.filePath, + lineNumber: parsedLocation.lineNumber, + columnNumber: parsedLocation.columnNumber, + componentName, + }; +}; + +const resolveFromLocatorId = (locatorId: string): ElementSourceInfo | null => { + const parsedLocatorId = parseLocatorId(locatorId); + if (!parsedLocatorId) return null; + + const fileData = getLocatorFileData(parsedLocatorId.filePath); + const expressionList = fileData ? readArray(fileData.expressions) : []; + const expression = expressionList[parsedLocatorId.expressionId]; + const expressionStart = getExpressionStart(expression); + const componentName = + fileData && expression ? getWrappingComponentName(fileData, expression) : null; + + return { + filePath: parsedLocatorId.filePath, + lineNumber: expressionStart?.lineNumber ?? null, + columnNumber: expressionStart?.columnNumber ?? null, + componentName, + }; +}; + +const resolveFromSolidDevtoolsLocation = ( + solidDevtoolsLocation: string, +): ElementSourceInfo | null => { + const parsedLocation = parseSourceLocation(solidDevtoolsLocation); + if (!parsedLocation) return null; + + return { + filePath: normalizeSolidDevtoolsFilePath(parsedLocation.filePath), + lineNumber: parsedLocation.lineNumber, + columnNumber: parsedLocation.columnNumber, + componentName: null, + }; +}; + +export const getSolidSourceInfo = (element: Element): ElementSourceInfo | null => { + const sourceElement = element.closest(LOCATOR_ATTRIBUTE_SELECTOR); + if (!sourceElement) return null; + + const locatorPath = sourceElement.getAttribute(LOCATOR_PATH_ATTRIBUTE_NAME); + if (locatorPath) { + const locatorPathInfo = resolveFromLocatorPath(locatorPath); + if (locatorPathInfo) return locatorPathInfo; + } + + const locatorId = sourceElement.getAttribute(LOCATOR_ID_ATTRIBUTE_NAME); + if (locatorId) { + const locatorIdInfo = resolveFromLocatorId(locatorId); + if (locatorIdInfo) return locatorIdInfo; + } + + const solidDevtoolsLocation = sourceElement.getAttribute( + SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME, + ); + if (solidDevtoolsLocation) { + const solidDevtoolsInfo = + resolveFromSolidDevtoolsLocation(solidDevtoolsLocation); + if (solidDevtoolsInfo) return solidDevtoolsInfo; + } + + return null; +}; diff --git a/packages/react-grab/src/utils/get-svelte-source-info.ts b/packages/react-grab/src/utils/get-svelte-source-info.ts new file mode 100644 index 000000000..56b324e4d --- /dev/null +++ b/packages/react-grab/src/utils/get-svelte-source-info.ts @@ -0,0 +1,69 @@ +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; +}; + +export const getSvelteSourceInfo = ( + element: Element, +): ElementSourceInfo | null => { + const svelteMeta = getNearestSvelteMeta(element); + if (!svelteMeta) return null; + + const sourceLocation = readSvelteLocation(svelteMeta); + if (!sourceLocation) return null; + + return { + filePath: sourceLocation.filePath, + lineNumber: sourceLocation.lineNumber, + columnNumber: sourceLocation.columnNumber, + componentName: readComponentNameFromParentMeta(svelteMeta), + }; +}; diff --git a/packages/react-grab/src/utils/get-vue-source-info.ts b/packages/react-grab/src/utils/get-vue-source-info.ts new file mode 100644 index 000000000..ca6b8cf28 --- /dev/null +++ b/packages/react-grab/src/utils/get-vue-source-info.ts @@ -0,0 +1,99 @@ +import type { ElementSourceInfo } from "../types.js"; +import { parseSourceLocation } from "./parse-source-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 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 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 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, + }; +}; + +const resolveFromVueRuntimeMetadata = ( + element: Element, +): ElementSourceInfo | null => { + const nearestComponent = getNearestVueParentComponent(element); + const nearestComponentType = getVueComponentType(nearestComponent); + const filePath = getVueComponentFilePath(nearestComponentType); + if (!filePath) return null; + + return { + filePath, + lineNumber: null, + columnNumber: null, + componentName: getVueComponentName(nearestComponentType), + }; +}; + +export const getVueSourceInfo = (element: Element): ElementSourceInfo | null => { + const inspectorInfo = resolveFromInspectorAttribute(element); + if (inspectorInfo) return inspectorInfo; + return resolveFromVueRuntimeMetadata(element); +}; diff --git a/packages/react-grab/src/utils/parse-source-location.ts b/packages/react-grab/src/utils/parse-source-location.ts new file mode 100644 index 000000000..d6f0d2f6b --- /dev/null +++ b/packages/react-grab/src/utils/parse-source-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, + }; +}; From dc323de763508846e5b229a15b77ef9c4e1c29b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 11:56:14 +0000 Subject: [PATCH 03/15] add framework source plugin Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 2 ++ .../src/core/plugins/framework-source.ts | 23 +++++++++++++++++++ packages/react-grab/src/index.ts | 1 + 3 files changed, 26 insertions(+) create mode 100644 packages/react-grab/src/core/plugins/framework-source.ts diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 7eb25c429..48c876bb1 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -139,6 +139,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 +166,7 @@ const builtInPlugins = [ copyHtmlPlugin, copyStylesPlugin, openPlugin, + frameworkSourcePlugin, ]; let hasInited = false; 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..fe69e4f3c --- /dev/null +++ b/packages/react-grab/src/core/plugins/framework-source.ts @@ -0,0 +1,23 @@ +import type { Plugin } from "../../types.js"; +import { appendStackContext } from "../../utils/append-stack-context.js"; +import { + getFrameworkComponentName, + getFrameworkSourceInfo, + getFrameworkStackContext, +} from "../../utils/get-framework-source-info.js"; + +export const frameworkSourcePlugin: Plugin = { + name: "framework-source", + hooks: { + resolveElementSource: (element) => getFrameworkSourceInfo(element), + resolveElementComponentName: (element) => getFrameworkComponentName(element), + resolveElementStackContext: (element, options) => + getFrameworkStackContext(element, options), + transformSnippet: async (snippet, element) => { + const stackContext = getFrameworkStackContext(element); + if (!stackContext) return snippet; + if (snippet.includes(stackContext)) return snippet; + return appendStackContext(snippet, stackContext); + }, + }, +}; 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, From be31415e8615750101676a7dc04d0fa96f75fc5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 12:02:22 +0000 Subject: [PATCH 04/15] wire core source resolution through plugin fallback Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 123 ++++++++++++++----------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 48c876bb1..4103ca456 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -28,12 +28,10 @@ import { getStack, getStackContext, getNearestComponentName, - checkIsSourceComponentName, getComponentDisplayName, resolveSourceFromStack, checkIsNextProject, } from "./context.js"; -import { isSourceFile, normalizeFileName } from "bippy/source"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; import { tryCopyWithFallback } from "./copy.js"; @@ -111,6 +109,8 @@ import type { PerformWithFeedbackOptions, SettableOptions, SourceInfo, + ElementSourceInfo, + ElementStackContextOptions, Plugin, ToolbarState, HistoryItem, @@ -529,6 +529,48 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => isDragging(), ); + const resolveElementSourceInfo = async ( + element: Element, + ): Promise => { + const stack = await getStack(element); + const sourceFromStack = resolveSourceFromStack(stack); + if (sourceFromStack) { + return { + filePath: sourceFromStack.filePath, + lineNumber: sourceFromStack.lineNumber ?? null, + columnNumber: null, + componentName: sourceFromStack.componentName, + }; + } + + return pluginRegistry.hooks.resolveElementSource(element); + }; + + const resolveElementComponentName = async ( + element: Element, + ): Promise => { + const sourceComponentName = await getNearestComponentName(element); + if (sourceComponentName) return sourceComponentName; + + const pluginComponentName = + await pluginRegistry.hooks.resolveElementComponentName(element); + if (pluginComponentName) return pluginComponentName; + + return getComponentDisplayName(element); + }; + + const resolveElementStackContext = async ( + element: Element, + options: ElementStackContextOptions = {}, + ): Promise => { + const stackContext = await getStackContext(element, options); + if (stackContext) return stackContext; + + const pluginStackContext = + await pluginRegistry.hooks.resolveElementStackContext(element, options); + return pluginStackContext ?? ""; + }; + const isRendererActive = createMemo(() => isActivated() && !isCopying()); const crosshairVisible = createMemo( @@ -569,38 +611,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 @@ -1000,7 +1018,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }) : null; - void getNearestComponentName(element).then((componentName) => { + void resolveElementComponentName(element).then((componentName) => { void executeCopyOperation({ positionX: labelPositionX, operation: () => @@ -1288,18 +1306,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(); }) @@ -3170,7 +3185,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } - getNearestComponentName(element) + resolveElementComponentName(element) .then((name) => { if (componentNameRequestVersion !== currentVersion) return; setResolvedComponentName(name ?? undefined); @@ -3318,7 +3333,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; }, ); @@ -3327,8 +3342,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); }, ); @@ -3534,7 +3548,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, @@ -4273,16 +4287,15 @@ 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: (element: Element) => resolveElementStackContext(element), getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), From 0ea1d79fa2341f71f68a0a41a12d39dd91fd52ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 12:09:51 +0000 Subject: [PATCH 05/15] add e2e coverage for framework metadata source mapping Co-authored-by: Aiden Bai --- .../e2e/framework-source-metadata.spec.ts | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 packages/react-grab/e2e/framework-source-metadata.spec.ts 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..75ace58ef --- /dev/null +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -0,0 +1,291 @@ +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 resolve Solid locator metadata to source information", async ({ + reactGrab, + }) => { + const solidFilePath = "/workspace/solid/src/components/counter.tsx"; + + await reactGrab.page.evaluate(({ filePath, targetStyle }) => { + const element = document.createElement("button"); + element.id = "solid-metadata-target"; + element.textContent = "Solid Metadata Target"; + element.setAttribute("data-locatorjs-id", `${filePath}::0`); + Object.assign(element.style, targetStyle); + document.body.appendChild(element); + + ( + window as { + __LOCATOR_DATA__?: Record; + } + ).__LOCATOR_DATA__ = { + [filePath]: { + filePath, + projectPath: "/workspace/solid", + expressions: [ + { + name: "button", + loc: { + start: { line: 12, column: 6 }, + end: { line: 12, column: 28 }, + }, + wrappingComponentId: 0, + }, + ], + styledDefinitions: [], + components: [ + { + name: "SolidCounter", + loc: { + start: { line: 4, column: 0 }, + end: { line: 18, column: 1 }, + }, + }, + ], + }, + }; + }, { 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: 12, + componentName: "SolidCounter", + }); + + 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("SolidCounter"); + expect(stackContext).toContain(`${solidFilePath}:12:6`); + + await reactGrab.activate(); + await reactGrab.hoverElement("#solid-metadata-target"); + await reactGrab.waitForSelectionBox(); + await reactGrab.waitForSelectionSource(); + + const state = await reactGrab.getState(); + expect(state.selectionFilePath).toBe(solidFilePath); + + await reactGrab.clickElement("#solid-metadata-target"); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain("SolidCounter"); + expect(clipboard).toContain(`${solidFilePath}:12:6`); + }); + + 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 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", + }, + }; + 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); + }); + + 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: null, + 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"); + }); +}); From 78b8a16ecffc6d819738553edb75c811b2336c5b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 12:12:17 +0000 Subject: [PATCH 06/15] document solid vue svelte source metadata support Co-authored-by: Aiden Bai --- packages/grab/README.md | 12 ++++++++++++ packages/react-grab/README.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/grab/README.md b/packages/grab/README.md index 94f63167a..eeefb34b6 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -44,6 +44,18 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` +## Framework Source Metadata (Development) + +Grab supports framework metadata fallbacks when React stack traces are unavailable: + +- **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. +- **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). +- **Solid**: requires location metadata injection (for example `@locator/babel-jsx` with `vite-plugin-solid`) so elements expose `data-locatorjs-id` / `data-locatorjs`. + +When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. + +These metadata channels are development-only and are usually stripped in production builds. + ## Manual Installation If you're using a React framework or build tool, view instructions below: diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 65a25558f..c43629468 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -44,6 +44,18 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` +## Framework Source Metadata (Development) + +React Grab now supports framework metadata fallbacks when React stack traces are unavailable: + +- **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. +- **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). +- **Solid**: requires location metadata injection (for example `@locator/babel-jsx` with `vite-plugin-solid`) so elements expose `data-locatorjs-id` / `data-locatorjs`. + +When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. + +These metadata channels are development-only and are usually stripped in production builds. + ## Manual Installation If you're using a React framework or build tool, view instructions below: From a102a40f28c14303387cb7dfee7eb0f7c9c8963f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 12:26:43 +0000 Subject: [PATCH 07/15] format framework source changes and fix e2e typing Co-authored-by: Aiden Bai --- .../e2e/framework-source-metadata.spec.ts | 125 ++++++++++-------- packages/react-grab/src/core/index.tsx | 3 +- .../src/core/plugins/framework-source.ts | 3 +- .../src/utils/get-solid-source-info.ts | 18 ++- .../src/utils/get-svelte-source-info.ts | 8 +- .../src/utils/get-vue-source-info.ts | 8 +- 6 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/react-grab/e2e/framework-source-metadata.spec.ts b/packages/react-grab/e2e/framework-source-metadata.spec.ts index 75ace58ef..8d63a8475 100644 --- a/packages/react-grab/e2e/framework-source-metadata.spec.ts +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -16,45 +16,48 @@ test.describe("Framework Source Metadata", () => { }) => { const solidFilePath = "/workspace/solid/src/components/counter.tsx"; - await reactGrab.page.evaluate(({ filePath, targetStyle }) => { - const element = document.createElement("button"); - element.id = "solid-metadata-target"; - element.textContent = "Solid Metadata Target"; - element.setAttribute("data-locatorjs-id", `${filePath}::0`); - Object.assign(element.style, targetStyle); - document.body.appendChild(element); + await reactGrab.page.evaluate( + ({ filePath, targetStyle }) => { + const element = document.createElement("button"); + element.id = "solid-metadata-target"; + element.textContent = "Solid Metadata Target"; + element.setAttribute("data-locatorjs-id", `${filePath}::0`); + Object.assign(element.style, targetStyle); + document.body.appendChild(element); - ( - window as { - __LOCATOR_DATA__?: Record; - } - ).__LOCATOR_DATA__ = { - [filePath]: { - filePath, - projectPath: "/workspace/solid", - expressions: [ - { - name: "button", - loc: { - start: { line: 12, column: 6 }, - end: { line: 12, column: 28 }, + ( + window as { + __LOCATOR_DATA__?: Record; + } + ).__LOCATOR_DATA__ = { + [filePath]: { + filePath, + projectPath: "/workspace/solid", + expressions: [ + { + name: "button", + loc: { + start: { line: 12, column: 6 }, + end: { line: 12, column: 28 }, + }, + wrappingComponentId: 0, }, - wrappingComponentId: 0, - }, - ], - styledDefinitions: [], - components: [ - { - name: "SolidCounter", - loc: { - start: { line: 4, column: 0 }, - end: { line: 18, column: 1 }, + ], + styledDefinitions: [], + components: [ + { + name: "SolidCounter", + loc: { + start: { line: 4, column: 0 }, + end: { line: 18, column: 1 }, + }, }, - }, - ], - }, - }; - }, { filePath: solidFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }); + ], + }, + }; + }, + { filePath: solidFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }, + ); const source = await reactGrab.page.evaluate(async () => { const api = ( @@ -96,8 +99,17 @@ test.describe("Framework Source Metadata", () => { await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionSource(); - const state = await reactGrab.getState(); - expect(state.selectionFilePath).toBe(solidFilePath); + 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"); @@ -169,23 +181,26 @@ test.describe("Framework Source Metadata", () => { }) => { const vueRuntimeFilePath = "/workspace/vue/src/components/Fallback.vue"; - await reactGrab.page.evaluate(({ filePath, targetStyle }) => { - 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", - }, - }; - document.body.appendChild(element); - }, { filePath: vueRuntimeFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }); + await reactGrab.page.evaluate( + ({ filePath, targetStyle }) => { + 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", + }, + }; + document.body.appendChild(element); + }, + { filePath: vueRuntimeFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }, + ); const source = await reactGrab.page.evaluate(async () => { const api = ( diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 4103ca456..1fb188124 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -4295,7 +4295,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext: (element: Element) => resolveElementStackContext(element), + getStackContext: (element: Element) => + resolveElementStackContext(element), getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), diff --git a/packages/react-grab/src/core/plugins/framework-source.ts b/packages/react-grab/src/core/plugins/framework-source.ts index fe69e4f3c..2cf738584 100644 --- a/packages/react-grab/src/core/plugins/framework-source.ts +++ b/packages/react-grab/src/core/plugins/framework-source.ts @@ -10,7 +10,8 @@ export const frameworkSourcePlugin: Plugin = { name: "framework-source", hooks: { resolveElementSource: (element) => getFrameworkSourceInfo(element), - resolveElementComponentName: (element) => getFrameworkComponentName(element), + resolveElementComponentName: (element) => + getFrameworkComponentName(element), resolveElementStackContext: (element, options) => getFrameworkStackContext(element, options), transformSnippet: async (snippet, element) => { diff --git a/packages/react-grab/src/utils/get-solid-source-info.ts b/packages/react-grab/src/utils/get-solid-source-info.ts index 6804860c0..57e361b91 100644 --- a/packages/react-grab/src/utils/get-solid-source-info.ts +++ b/packages/react-grab/src/utils/get-solid-source-info.ts @@ -103,7 +103,10 @@ const findExpressionByLocation = ( for (const expression of expressionList) { const start = getExpressionStart(expression); if (!start) continue; - if (start.lineNumber === lineNumber && start.columnNumber === columnNumber) { + if ( + start.lineNumber === lineNumber && + start.columnNumber === columnNumber + ) { return expression; } } @@ -164,7 +167,9 @@ const resolveFromLocatorId = (locatorId: string): ElementSourceInfo | null => { const expression = expressionList[parsedLocatorId.expressionId]; const expressionStart = getExpressionStart(expression); const componentName = - fileData && expression ? getWrappingComponentName(fileData, expression) : null; + fileData && expression + ? getWrappingComponentName(fileData, expression) + : null; return { filePath: parsedLocatorId.filePath, @@ -188,7 +193,9 @@ const resolveFromSolidDevtoolsLocation = ( }; }; -export const getSolidSourceInfo = (element: Element): ElementSourceInfo | null => { +export const getSolidSourceInfo = ( + element: Element, +): ElementSourceInfo | null => { const sourceElement = element.closest(LOCATOR_ATTRIBUTE_SELECTOR); if (!sourceElement) return null; @@ -208,8 +215,9 @@ export const getSolidSourceInfo = (element: Element): ElementSourceInfo | null = SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME, ); if (solidDevtoolsLocation) { - const solidDevtoolsInfo = - resolveFromSolidDevtoolsLocation(solidDevtoolsLocation); + const solidDevtoolsInfo = resolveFromSolidDevtoolsLocation( + solidDevtoolsLocation, + ); if (solidDevtoolsInfo) return solidDevtoolsInfo; } diff --git a/packages/react-grab/src/utils/get-svelte-source-info.ts b/packages/react-grab/src/utils/get-svelte-source-info.ts index 56b324e4d..0c4b97fa1 100644 --- a/packages/react-grab/src/utils/get-svelte-source-info.ts +++ b/packages/react-grab/src/utils/get-svelte-source-info.ts @@ -12,7 +12,9 @@ const readString = (value: unknown): string | null => const readNumber = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; -const getNearestSvelteMeta = (element: Element): Record | null => { +const getNearestSvelteMeta = ( + element: Element, +): Record | null => { let currentElement: Element | null = element; while (currentElement) { const svelteMeta = Reflect.get(currentElement, SVELTE_META_PROPERTY_NAME); @@ -41,7 +43,9 @@ const readSvelteLocation = ( }; }; -const readComponentNameFromParentMeta = (svelteMeta: Record) => { +const readComponentNameFromParentMeta = ( + svelteMeta: Record, +) => { let currentParent = svelteMeta.parent; while (isRecord(currentParent)) { const componentTag = readString(currentParent.componentTag); diff --git a/packages/react-grab/src/utils/get-vue-source-info.ts b/packages/react-grab/src/utils/get-vue-source-info.ts index ca6b8cf28..fd7db233a 100644 --- a/packages/react-grab/src/utils/get-vue-source-info.ts +++ b/packages/react-grab/src/utils/get-vue-source-info.ts @@ -58,7 +58,9 @@ const resolveFromInspectorAttribute = ( const sourceElement = element.closest(VUE_INSPECTOR_SELECTOR); if (!sourceElement) return null; - const sourceLocation = sourceElement.getAttribute(VUE_INSPECTOR_ATTRIBUTE_NAME); + const sourceLocation = sourceElement.getAttribute( + VUE_INSPECTOR_ATTRIBUTE_NAME, + ); if (!sourceLocation) return null; const parsedLocation = parseSourceLocation(sourceLocation); @@ -92,7 +94,9 @@ const resolveFromVueRuntimeMetadata = ( }; }; -export const getVueSourceInfo = (element: Element): ElementSourceInfo | null => { +export const getVueSourceInfo = ( + element: Element, +): ElementSourceInfo | null => { const inspectorInfo = resolveFromInspectorAttribute(element); if (inspectorInfo) return inspectorInfo; return resolveFromVueRuntimeMetadata(element); From e996766416160b0e805206f7e27f7b7050a94fc9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 23:52:22 +0000 Subject: [PATCH 08/15] switch Solid mapping to runtime handler heuristics Co-authored-by: Aiden Bai --- packages/grab/README.md | 2 +- packages/react-grab/README.md | 2 +- .../e2e/framework-source-metadata.spec.ts | 66 ++- .../src/core/plugins/framework-source.ts | 2 +- .../src/utils/get-framework-source-info.ts | 62 +-- .../src/utils/get-solid-source-info.ts | 417 ++++++++++-------- 6 files changed, 305 insertions(+), 246 deletions(-) diff --git a/packages/grab/README.md b/packages/grab/README.md index eeefb34b6..c339fa305 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -50,7 +50,7 @@ Grab supports framework metadata fallbacks when React stack traces are unavailab - **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. - **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). -- **Solid**: requires location metadata injection (for example `@locator/babel-jsx` with `vite-plugin-solid`) so elements expose `data-locatorjs-id` / `data-locatorjs`. +- **Solid**: uses runtime delegated handler internals (`$$click`, etc.) and scans loaded Vite source modules for the matching handler body, then infers source from nearby compiler location literals. When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index c43629468..3ce33f7b4 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -50,7 +50,7 @@ React Grab now supports framework metadata fallbacks when React stack traces are - **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. - **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). -- **Solid**: requires location metadata injection (for example `@locator/babel-jsx` with `vite-plugin-solid`) so elements expose `data-locatorjs-id` / `data-locatorjs`. +- **Solid**: uses runtime delegated handler internals (`$$click`, etc.) and scans loaded Vite source modules for the matching handler body, then infers source from nearby compiler location literals. When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. diff --git a/packages/react-grab/e2e/framework-source-metadata.spec.ts b/packages/react-grab/e2e/framework-source-metadata.spec.ts index 8d63a8475..9b1338f9c 100644 --- a/packages/react-grab/e2e/framework-source-metadata.spec.ts +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -11,50 +11,48 @@ const PLACEHOLDER_TARGET_STYLE = { }; test.describe("Framework Source Metadata", () => { - test("should resolve Solid locator metadata to source information", async ({ + test("should resolve Solid runtime handler source without build plugins", async ({ reactGrab, }) => { - const solidFilePath = "/workspace/solid/src/components/counter.tsx"; + const solidFilePath = "src/components/counter.tsx"; await reactGrab.page.evaluate( ({ filePath, targetStyle }) => { const element = document.createElement("button"); element.id = "solid-metadata-target"; element.textContent = "Solid Metadata Target"; - element.setAttribute("data-locatorjs-id", `${filePath}::0`); 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 { - __LOCATOR_DATA__?: Record; + __REACT_GRAB_SOLID_RUNTIME_MODULES__?: Array<{ + url: string; + content: string; + }>; } - ).__LOCATOR_DATA__ = { - [filePath]: { - filePath, - projectPath: "/workspace/solid", - expressions: [ - { - name: "button", - loc: { - start: { line: 12, column: 6 }, - end: { line: 12, column: 28 }, - }, - wrappingComponentId: 0, - }, - ], - styledDefinitions: [], - components: [ - { - name: "SolidCounter", - loc: { - start: { line: 4, column: 0 }, - end: { line: 18, column: 1 }, - }, - }, - ], + ).__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(template, { location: "${filePath}:14:2" }); + `, }, - }; + ]; }, { filePath: solidFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }, ); @@ -74,8 +72,8 @@ test.describe("Framework Source Metadata", () => { expect(source).toEqual({ filePath: solidFilePath, - lineNumber: 12, - componentName: "SolidCounter", + lineNumber: 14, + componentName: null, }); const stackContext = await reactGrab.page.evaluate(async () => { @@ -91,8 +89,7 @@ test.describe("Framework Source Metadata", () => { return api.getStackContext(element); }); - expect(stackContext).toContain("SolidCounter"); - expect(stackContext).toContain(`${solidFilePath}:12:6`); + expect(stackContext).toContain(`${solidFilePath}:14:2`); await reactGrab.activate(); await reactGrab.hoverElement("#solid-metadata-target"); @@ -114,8 +111,7 @@ test.describe("Framework Source Metadata", () => { await reactGrab.clickElement("#solid-metadata-target"); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toContain("SolidCounter"); - expect(clipboard).toContain(`${solidFilePath}:12:6`); + expect(clipboard).toContain(`${solidFilePath}:14:2`); }); test("should resolve Vue inspector metadata with line and column", async ({ diff --git a/packages/react-grab/src/core/plugins/framework-source.ts b/packages/react-grab/src/core/plugins/framework-source.ts index 2cf738584..0c3591831 100644 --- a/packages/react-grab/src/core/plugins/framework-source.ts +++ b/packages/react-grab/src/core/plugins/framework-source.ts @@ -15,7 +15,7 @@ export const frameworkSourcePlugin: Plugin = { resolveElementStackContext: (element, options) => getFrameworkStackContext(element, options), transformSnippet: async (snippet, element) => { - const stackContext = getFrameworkStackContext(element); + const stackContext = await getFrameworkStackContext(element); if (!stackContext) return snippet; if (snippet.includes(stackContext)) return snippet; return appendStackContext(snippet, stackContext); diff --git a/packages/react-grab/src/utils/get-framework-source-info.ts b/packages/react-grab/src/utils/get-framework-source-info.ts index 25d560839..c56035e86 100644 --- a/packages/react-grab/src/utils/get-framework-source-info.ts +++ b/packages/react-grab/src/utils/get-framework-source-info.ts @@ -7,9 +7,9 @@ import { getSolidSourceInfo } from "./get-solid-source-info.js"; import { getSvelteSourceInfo } from "./get-svelte-source-info.js"; import { getVueSourceInfo } from "./get-vue-source-info.js"; -const getResolvedFrameworkSourceInfo = ( +const getResolvedFrameworkSourceInfo = async ( element: Element, -): ElementSourceInfo | null => { +): Promise => { const resolvers = [ getSvelteSourceInfo, getVueSourceInfo, @@ -17,7 +17,7 @@ const getResolvedFrameworkSourceInfo = ( ] as const; for (const resolveSourceInfo of resolvers) { - const sourceInfo = resolveSourceInfo(element); + const sourceInfo = await resolveSourceInfo(element); if (!sourceInfo) continue; if (!sourceInfo.filePath) continue; return sourceInfo; @@ -39,42 +39,42 @@ const formatSourceLocation = (sourceInfo: ElementSourceInfo): string => { export const getFrameworkSourceInfo = ( element: Element, -): ElementSourceInfo | null => { - return getResolvedFrameworkSourceInfo(element); -}; +): Promise => getResolvedFrameworkSourceInfo(element); export const getFrameworkSourceInfoForApi = ( element: Element, -): SourceInfo | null => { - const sourceInfo = getResolvedFrameworkSourceInfo(element); - if (!sourceInfo) return null; - return { - filePath: sourceInfo.filePath, - lineNumber: sourceInfo.lineNumber, - componentName: sourceInfo.componentName, - }; -}; +): Promise => + getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { + if (!sourceInfo) return null; + return { + filePath: sourceInfo.filePath, + lineNumber: sourceInfo.lineNumber, + componentName: sourceInfo.componentName, + }; + }); -export const getFrameworkComponentName = (element: Element): string | null => { - const sourceInfo = getResolvedFrameworkSourceInfo(element); - if (!sourceInfo) return null; - return sourceInfo.componentName; -}; +export const getFrameworkComponentName = ( + element: Element, +): Promise => + getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { + if (!sourceInfo) return null; + return sourceInfo.componentName; + }); export const getFrameworkStackContext = ( element: Element, options: ElementStackContextOptions = {}, -): string => { - const { maxLines = 3 } = options; - if (maxLines < 1) return ""; +): Promise => + getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { + const { maxLines = 3 } = options; + if (maxLines < 1) return ""; - const sourceInfo = getResolvedFrameworkSourceInfo(element); - if (!sourceInfo) return ""; + if (!sourceInfo) return ""; - const sourceLocation = formatSourceLocation(sourceInfo); - if (sourceInfo.componentName) { - return `\n in ${sourceInfo.componentName} (at ${sourceLocation})`; - } + const sourceLocation = formatSourceLocation(sourceInfo); + if (sourceInfo.componentName) { + return `\n in ${sourceInfo.componentName} (at ${sourceLocation})`; + } - return `\n in ${sourceLocation}`; -}; + return `\n in ${sourceLocation}`; + }); diff --git a/packages/react-grab/src/utils/get-solid-source-info.ts b/packages/react-grab/src/utils/get-solid-source-info.ts index 57e361b91..0a79eea60 100644 --- a/packages/react-grab/src/utils/get-solid-source-info.ts +++ b/packages/react-grab/src/utils/get-solid-source-info.ts @@ -1,21 +1,38 @@ import type { ElementSourceInfo } from "../types.js"; import { parseSourceLocation } from "./parse-source-location.js"; -interface LocatorExpressionStart { - lineNumber: number; - columnNumber: number; +interface SolidRuntimeModuleRecord { + url: string; + content: string; } -const LOCATOR_ID_SEPARATOR = "::"; -const LOCATOR_PATH_ATTRIBUTE_NAME = "data-locatorjs"; -const LOCATOR_ID_ATTRIBUTE_NAME = "data-locatorjs-id"; -const SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME = "data-source-loc"; -const SOLID_DEVTOOLS_PROJECT_PATH_GLOBAL_NAME = "$sdt_projectPath"; -const LOCATOR_DATA_GLOBAL_NAME = "__LOCATOR_DATA__"; -const ABSOLUTE_UNIX_PREFIX = "/"; -const RELATIVE_CURRENT_PREFIX = "./"; -const ABSOLUTE_WINDOWS_PATTERN = /^[a-zA-Z]:\\/; -const LOCATOR_ATTRIBUTE_SELECTOR = `[${LOCATOR_PATH_ATTRIBUTE_NAME}], [${LOCATOR_ID_ATTRIBUTE_NAME}], [${SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME}]`; +interface SolidHandlerCandidate { + source: string; +} + +interface SolidHandlerSourceMatch { + moduleUrl: string; + moduleContent: string; + handlerSourceIndex: 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_SOURCE_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; @@ -23,203 +40,249 @@ const isRecord = (value: unknown): value is Record => 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 readArray = (value: unknown): unknown[] => - Array.isArray(value) ? value : []; - -const isAbsoluteFilePath = (filePath: string): boolean => - filePath.startsWith(ABSOLUTE_UNIX_PREFIX) || - ABSOLUTE_WINDOWS_PATTERN.test(filePath); - -const normalizeSolidDevtoolsFilePath = (filePath: string): string => { - if (typeof window === "undefined") return filePath; - if (isAbsoluteFilePath(filePath)) return filePath; - - const projectPath = readString( - Reflect.get(window, SOLID_DEVTOOLS_PROJECT_PATH_GLOBAL_NAME), +const readRuntimeModulesFromWindow = (): SolidRuntimeModuleRecord[] => { + if (typeof window === "undefined") return []; + const rawRuntimeModules = Reflect.get( + window, + SOLID_RUNTIME_MODULES_GLOBAL_NAME, ); - if (!projectPath) return filePath; - - const normalizedProjectPath = projectPath.endsWith(ABSOLUTE_UNIX_PREFIX) - ? projectPath.slice(0, -1) - : projectPath; - const normalizedFilePath = filePath.startsWith(RELATIVE_CURRENT_PREFIX) - ? filePath.slice(2) - : filePath; - - return `${normalizedProjectPath}/${normalizedFilePath}`; + 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 getLocatorDataRecord = (): Record | null => { - if (typeof window === "undefined") return null; - const locatorData = Reflect.get(window, LOCATOR_DATA_GLOBAL_NAME); - return isRecord(locatorData) ? locatorData : null; +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 getLocatorFileData = ( - filePath: string, -): Record | null => { - const locatorData = getLocatorDataRecord(); - if (!locatorData) return null; - const fileData = locatorData[filePath]; - return isRecord(fileData) ? fileData : null; -}; +const readSourceModuleUrlsFromPerformance = (): string[] => { + if (typeof window === "undefined") return []; + const resourceEntries = performance.getEntriesByType("resource"); + const uniqueModuleUrls = new Set(); -const getExpressionStart = ( - expression: unknown, -): LocatorExpressionStart | null => { - if (!isRecord(expression)) return null; - const location = expression.loc; - if (!isRecord(location)) return null; - const start = location.start; - if (!isRecord(start)) return null; - const lineNumber = readNumber(start.line); - const columnNumber = readNumber(start.column); - if (lineNumber === null || columnNumber === null) return null; - return { lineNumber, columnNumber }; + for (const resourceEntry of resourceEntries) { + const resourceUrl = resourceEntry.name; + if (!resourceUrl) continue; + if (!shouldIncludeSourceModule(resourceUrl)) continue; + uniqueModuleUrls.add(resourceUrl); + } + + return Array.from(uniqueModuleUrls); }; -const getWrappingComponentName = ( - fileData: Record, - expression: unknown, -): string | null => { - if (!isRecord(expression)) return null; - const wrappingComponentId = readNumber(expression.wrappingComponentId); - if (wrappingComponentId === null) return null; - if (!Number.isInteger(wrappingComponentId)) return null; - const componentList = readArray(fileData.components); - const component = componentList[wrappingComponentId]; - if (!isRecord(component)) return null; - return readString(component.name); +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 findExpressionByLocation = ( - expressionList: unknown[], - lineNumber: number, - columnNumber: number, -): unknown | null => { - for (const expression of expressionList) { - const start = getExpressionStart(expression); - if (!start) continue; - if ( - start.lineNumber === lineNumber && - start.columnNumber === columnNumber - ) { - return expression; +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 parseLocatorId = ( - locatorId: string, -): { filePath: string; expressionId: number } | null => { - const separatorIndex = locatorId.lastIndexOf(LOCATOR_ID_SEPARATOR); - if (separatorIndex === -1) return null; - - const filePath = locatorId.slice(0, separatorIndex); - const rawExpressionId = locatorId.slice( - separatorIndex + LOCATOR_ID_SEPARATOR.length, +const parseNearestLocationLiteral = ( + moduleContent: string, + handlerSourceIndex: number, +): { filePath: string; lineNumber: number; columnNumber: number } | null => { + 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 expressionId = Number.parseInt(rawExpressionId, 10); + const contextWindowText = moduleContent.slice( + contextWindowStartIndex, + contextWindowEndIndex, + ); + + let nearestLocation: { + filePath: string; + lineNumber: number; + columnNumber: number; + } | null = null; + let nearestLocationDistance = Number.POSITIVE_INFINITY; + + 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); + + if (locationDistance >= nearestLocationDistance) continue; + nearestLocationDistance = locationDistance; + nearestLocation = parsedLocation; + } - if (!filePath) return null; - if (Number.isNaN(expressionId)) return null; - if (expressionId < 0) return null; + return nearestLocation; +}; - return { filePath, expressionId }; +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 resolveFromLocatorPath = ( - locatorPath: string, -): ElementSourceInfo | null => { - const parsedLocation = parseSourceLocation(locatorPath); - if (!parsedLocation) return null; - - const fileData = getLocatorFileData(parsedLocation.filePath); - const expressionList = fileData ? readArray(fileData.expressions) : []; - const matchedExpression = findExpressionByLocation( - expressionList, - parsedLocation.lineNumber, - parsedLocation.columnNumber, +const getGeneratedLocationFromModule = ( + moduleContent: string, + handlerSourceIndex: number, +): { lineNumber: number; columnNumber: number } => { + const prefixContent = moduleContent.slice( + SOURCE_CONTEXT_WINDOW_START_CHARS, + handlerSourceIndex, ); - const componentName = - fileData && matchedExpression - ? getWrappingComponentName(fileData, matchedExpression) - : null; + const sourceLines = prefixContent.split("\n"); + const lineNumber = sourceLines.length; + const previousLine = sourceLines[sourceLines.length - 1] ?? ""; + const columnNumber = previousLine.length + SOURCE_LINE_START_COLUMN; return { - filePath: parsedLocation.filePath, - lineNumber: parsedLocation.lineNumber, - columnNumber: parsedLocation.columnNumber, - componentName, + lineNumber, + columnNumber, }; }; -const resolveFromLocatorId = (locatorId: string): ElementSourceInfo | null => { - const parsedLocatorId = parseLocatorId(locatorId); - if (!parsedLocatorId) return null; - - const fileData = getLocatorFileData(parsedLocatorId.filePath); - const expressionList = fileData ? readArray(fileData.expressions) : []; - const expression = expressionList[parsedLocatorId.expressionId]; - const expressionStart = getExpressionStart(expression); - const componentName = - fileData && expression - ? getWrappingComponentName(fileData, expression) - : null; +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 { - filePath: parsedLocatorId.filePath, - lineNumber: expressionStart?.lineNumber ?? null, - columnNumber: expressionStart?.columnNumber ?? null, - componentName, - }; + return null; }; -const resolveFromSolidDevtoolsLocation = ( - solidDevtoolsLocation: string, -): ElementSourceInfo | null => { - const parsedLocation = parseSourceLocation(solidDevtoolsLocation); - if (!parsedLocation) return null; +const resolveSolidSourceFromHandler = async ( + handlerSource: string, +): Promise => { + const cachedSourceInfo = SOLID_HANDLER_SOURCE_CACHE.get(handlerSource); + if (cachedSourceInfo) return cachedSourceInfo; - return { - filePath: normalizeSolidDevtoolsFilePath(parsedLocation.filePath), - lineNumber: parsedLocation.lineNumber, - columnNumber: parsedLocation.columnNumber, - componentName: null, - }; -}; + const sourceInfoPromise = (async () => { + const handlerSourceMatch = await resolveHandlerSourceMatch(handlerSource); + if (!handlerSourceMatch) return null; -export const getSolidSourceInfo = ( - element: Element, -): ElementSourceInfo | null => { - const sourceElement = element.closest(LOCATOR_ATTRIBUTE_SELECTOR); - if (!sourceElement) return null; - - const locatorPath = sourceElement.getAttribute(LOCATOR_PATH_ATTRIBUTE_NAME); - if (locatorPath) { - const locatorPathInfo = resolveFromLocatorPath(locatorPath); - if (locatorPathInfo) return locatorPathInfo; - } + const nearestLocationLiteral = parseNearestLocationLiteral( + handlerSourceMatch.moduleContent, + handlerSourceMatch.handlerSourceIndex, + ); + if (nearestLocationLiteral) { + return { + filePath: nearestLocationLiteral.filePath, + lineNumber: nearestLocationLiteral.lineNumber, + columnNumber: nearestLocationLiteral.columnNumber, + componentName: null, + }; + } - const locatorId = sourceElement.getAttribute(LOCATOR_ID_ATTRIBUTE_NAME); - if (locatorId) { - const locatorIdInfo = resolveFromLocatorId(locatorId); - if (locatorIdInfo) return locatorIdInfo; - } + const modulePath = toProjectRelativeModulePath( + handlerSourceMatch.moduleUrl, + ); + if (!modulePath) return null; - const solidDevtoolsLocation = sourceElement.getAttribute( - SOLID_DEVTOOLS_LOCATION_ATTRIBUTE_NAME, - ); - if (solidDevtoolsLocation) { - const solidDevtoolsInfo = resolveFromSolidDevtoolsLocation( - solidDevtoolsLocation, + const generatedLocation = getGeneratedLocationFromModule( + handlerSourceMatch.moduleContent, + handlerSourceMatch.handlerSourceIndex, ); - if (solidDevtoolsInfo) return solidDevtoolsInfo; - } - return null; + return { + filePath: modulePath, + lineNumber: generatedLocation.lineNumber, + columnNumber: generatedLocation.columnNumber, + componentName: null, + }; + })(); + + SOLID_HANDLER_SOURCE_CACHE.set(handlerSource, sourceInfoPromise); + return sourceInfoPromise; +}; + +export const getSolidSourceInfo = ( + element: Element, +): Promise => { + const solidHandlerCandidate = findSolidHandlerCandidate(element); + if (!solidHandlerCandidate) return Promise.resolve(null); + return resolveSolidSourceFromHandler(solidHandlerCandidate.source); }; From 9e4ba4b107de6e49320413b8b8a72bd99300c5ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 05:46:08 +0000 Subject: [PATCH 09/15] add vite playground environments for solid vue svelte Co-authored-by: Aiden Bai --- packages/solid-vite-playground/index.html | 12 + packages/solid-vite-playground/package.json | 18 + .../solid-vite-playground/src/app-root.jsx | 18 + packages/solid-vite-playground/src/main.jsx | 14 + packages/solid-vite-playground/vite.config.js | 6 + packages/svelte-vite-playground/index.html | 12 + packages/svelte-vite-playground/package.json | 18 + .../src/app-root.svelte | 15 + packages/svelte-vite-playground/src/main.js | 16 + .../svelte-vite-playground/vite.config.js | 6 + packages/vue-vite-playground/index.html | 12 + packages/vue-vite-playground/package.json | 18 + packages/vue-vite-playground/src/app-root.vue | 19 + packages/vue-vite-playground/src/main.js | 8 + packages/vue-vite-playground/vite.config.js | 6 + pnpm-lock.yaml | 498 +++++++++++++++++- 16 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 packages/solid-vite-playground/index.html create mode 100644 packages/solid-vite-playground/package.json create mode 100644 packages/solid-vite-playground/src/app-root.jsx create mode 100644 packages/solid-vite-playground/src/main.jsx create mode 100644 packages/solid-vite-playground/vite.config.js create mode 100644 packages/svelte-vite-playground/index.html create mode 100644 packages/svelte-vite-playground/package.json create mode 100644 packages/svelte-vite-playground/src/app-root.svelte create mode 100644 packages/svelte-vite-playground/src/main.js create mode 100644 packages/svelte-vite-playground/vite.config.js create mode 100644 packages/vue-vite-playground/index.html create mode 100644 packages/vue-vite-playground/package.json create mode 100644 packages/vue-vite-playground/src/app-root.vue create mode 100644 packages/vue-vite-playground/src/main.js create mode 100644 packages/vue-vite-playground/vite.config.js diff --git a/packages/solid-vite-playground/index.html b/packages/solid-vite-playground/index.html new file mode 100644 index 000000000..f477b492e --- /dev/null +++ b/packages/solid-vite-playground/index.html @@ -0,0 +1,12 @@ + + + + + + Solid Vite Playground + + +
+ + + diff --git a/packages/solid-vite-playground/package.json b/packages/solid-vite-playground/package.json new file mode 100644 index 000000000..1680b1081 --- /dev/null +++ b/packages/solid-vite-playground/package.json @@ -0,0 +1,18 @@ +{ + "name": "@react-grab/solid-vite-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-grab": "workspace:*", + "solid-js": "latest" + }, + "devDependencies": { + "vite": "latest", + "vite-plugin-solid": "latest" + } +} diff --git a/packages/solid-vite-playground/src/app-root.jsx b/packages/solid-vite-playground/src/app-root.jsx new file mode 100644 index 000000000..da6c1772a --- /dev/null +++ b/packages/solid-vite-playground/src/app-root.jsx @@ -0,0 +1,18 @@ +import { createSignal } from "solid-js"; + +export const AppRoot = () => { + const [counterValue, setCounterValue] = createSignal(0); + + return ( +
+

Solid Runtime Playground

+

Use this app to test React Grab source mapping with Solid.

+ +
+ ); +}; diff --git a/packages/solid-vite-playground/src/main.jsx b/packages/solid-vite-playground/src/main.jsx new file mode 100644 index 000000000..b516c19b4 --- /dev/null +++ b/packages/solid-vite-playground/src/main.jsx @@ -0,0 +1,14 @@ +import { render } from "solid-js/web"; +import { AppRoot } from "./app-root.jsx"; + +if (import.meta.env.DEV) { + import("react-grab"); +} + +const mountElement = document.getElementById("app"); + +if (!mountElement) { + throw new Error("Missing app mount element"); +} + +render(() => , mountElement); diff --git a/packages/solid-vite-playground/vite.config.js b/packages/solid-vite-playground/vite.config.js new file mode 100644 index 000000000..4a303c3c2 --- /dev/null +++ b/packages/solid-vite-playground/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [solid()], +}); diff --git a/packages/svelte-vite-playground/index.html b/packages/svelte-vite-playground/index.html new file mode 100644 index 000000000..ceba8e011 --- /dev/null +++ b/packages/svelte-vite-playground/index.html @@ -0,0 +1,12 @@ + + + + + + Svelte Vite Playground + + +
+ + + diff --git a/packages/svelte-vite-playground/package.json b/packages/svelte-vite-playground/package.json new file mode 100644 index 000000000..c9f72c938 --- /dev/null +++ b/packages/svelte-vite-playground/package.json @@ -0,0 +1,18 @@ +{ + "name": "@react-grab/svelte-vite-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-grab": "workspace:*", + "svelte": "latest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "latest", + "vite": "latest" + } +} diff --git a/packages/svelte-vite-playground/src/app-root.svelte b/packages/svelte-vite-playground/src/app-root.svelte new file mode 100644 index 000000000..1af1222cf --- /dev/null +++ b/packages/svelte-vite-playground/src/app-root.svelte @@ -0,0 +1,15 @@ + + +
+

Svelte Runtime Playground

+

Use this app to test React Grab source mapping with Svelte.

+ +
diff --git a/packages/svelte-vite-playground/src/main.js b/packages/svelte-vite-playground/src/main.js new file mode 100644 index 000000000..87e71e441 --- /dev/null +++ b/packages/svelte-vite-playground/src/main.js @@ -0,0 +1,16 @@ +import { mount } from "svelte"; +import AppRoot from "./app-root.svelte"; + +if (import.meta.env.DEV) { + import("react-grab"); +} + +const mountElement = document.getElementById("app"); + +if (!mountElement) { + throw new Error("Missing app mount element"); +} + +mount(AppRoot, { + target: mountElement, +}); diff --git a/packages/svelte-vite-playground/vite.config.js b/packages/svelte-vite-playground/vite.config.js new file mode 100644 index 000000000..8a6f4b5b1 --- /dev/null +++ b/packages/svelte-vite-playground/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], +}); diff --git a/packages/vue-vite-playground/index.html b/packages/vue-vite-playground/index.html new file mode 100644 index 000000000..594a56740 --- /dev/null +++ b/packages/vue-vite-playground/index.html @@ -0,0 +1,12 @@ + + + + + + Vue Vite Playground + + +
+ + + diff --git a/packages/vue-vite-playground/package.json b/packages/vue-vite-playground/package.json new file mode 100644 index 000000000..d743904b6 --- /dev/null +++ b/packages/vue-vite-playground/package.json @@ -0,0 +1,18 @@ +{ + "name": "@react-grab/vue-vite-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-grab": "workspace:*", + "vue": "latest" + }, + "devDependencies": { + "@vitejs/plugin-vue": "latest", + "vite": "latest" + } +} diff --git a/packages/vue-vite-playground/src/app-root.vue b/packages/vue-vite-playground/src/app-root.vue new file mode 100644 index 000000000..17345fe2e --- /dev/null +++ b/packages/vue-vite-playground/src/app-root.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/vue-vite-playground/src/main.js b/packages/vue-vite-playground/src/main.js new file mode 100644 index 000000000..94d9f68e6 --- /dev/null +++ b/packages/vue-vite-playground/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import AppRoot from "./app-root.vue"; + +if (import.meta.env.DEV) { + import("react-grab"); +} + +createApp(AppRoot).mount("#app"); diff --git a/packages/vue-vite-playground/vite.config.js b/packages/vue-vite-playground/vite.config.js new file mode 100644 index 000000000..6ea854713 --- /dev/null +++ b/packages/vue-vite-playground/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8d7287d2..51045025f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,6 +706,38 @@ importers: specifier: ^8.4.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + packages/solid-vite-playground: + dependencies: + react-grab: + specifier: workspace:* + version: link:../react-grab + solid-js: + specifier: latest + version: 1.9.11 + devDependencies: + vite: + specifier: latest + version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite-plugin-solid: + specifier: latest + version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + + packages/svelte-vite-playground: + dependencies: + react-grab: + specifier: workspace:* + version: link:../react-grab + svelte: + specifier: latest + version: 5.53.7 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: latest + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + vite: + specifier: latest + version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + packages/utils: devDependencies: tsup: @@ -758,6 +790,22 @@ importers: specifier: ^6.0.2 version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + packages/vue-vite-playground: + dependencies: + react-grab: + specifier: workspace:* + version: link:../react-grab + vue: + specifier: latest + version: 3.5.29(typescript@5.9.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: latest + version: 6.0.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.29(typescript@5.9.3)) + vite: + specifier: latest + version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + packages/web-extension: dependencies: react-grab: @@ -796,7 +844,7 @@ importers: version: link:../design-system '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 1.5.0(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3)) '@vercel/firewall': specifier: ^1.1.1 version: 1.1.1 @@ -847,10 +895,10 @@ importers: version: 3.4.0 torph: specifier: ^0.0.5 - version: 0.0.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 0.0.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3)) web-haptics: specifier: ^0.0.6 - version: 0.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 0.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3)) devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -1034,6 +1082,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -3062,6 +3115,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} cpu: [arm] @@ -3215,8 +3271,8 @@ packages: engines: {node: '>=20'} hasBin: true - '@sourcegraph/amp@0.0.1772802427-gaf6d64': - resolution: {integrity: sha512-3F9WzQMcXcKWsptzawTJ71eFS0OQdvTQWvgqZ1pGKrgUzudAFNlkwGg1ByNDCOa3nppYMIbJVpNK3yb1EawFCg==} + '@sourcegraph/amp@0.0.1773030805-g3a9cb3': + resolution: {integrity: sha512-LJQv1F0PC5vZNYn0nzg/i9P2pxVbU6kYWRMjD6t3u4eLa4UTo1Gxb9bTHFpNr/CmHWtRhC3jvx+TK94h+X9y3g==} engines: {node: '>=20'} hasBin: true @@ -3226,6 +3282,26 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -3618,6 +3694,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} @@ -3831,6 +3910,13 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -3860,6 +3946,35 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} + + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} + + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} + + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} + peerDependencies: + vue: 3.5.29 + + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3955,6 +4070,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4470,6 +4589,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -4502,6 +4625,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -4582,6 +4708,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4797,6 +4927,9 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esrap@2.2.3: + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -4805,6 +4938,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -5396,6 +5532,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5456,6 +5595,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -5684,6 +5827,9 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5755,6 +5901,10 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6014,6 +6164,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -6618,10 +6771,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -6713,6 +6876,14 @@ packages: solid-js@1.9.10: resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -6902,6 +7073,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.53.7: + resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} + engines: {node: '>=18'} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -7289,6 +7464,16 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-solid@2.11.10: + resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite-plugin-web-extension@4.5.0: resolution: {integrity: sha512-5e48v9GApUqIPl9Pgyp1baZfDSTiGkotnR7hH52S9us5f4OxK/JueeEfn1E/fTV/cSlhYu/0qTCpIINeTQVnog==} engines: {node: '>=16'} @@ -7333,6 +7518,54 @@ packages: yaml: optional: true + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7361,6 +7594,14 @@ packages: jsdom: optional: true + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -7516,6 +7757,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zip-dir@2.0.0: resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==} @@ -7667,7 +7911,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -7693,7 +7937,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -7730,6 +7974,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7818,7 +8066,6 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - optional: true '@base-ui/react@1.2.0(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: @@ -9647,6 +9894,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -9758,14 +10007,14 @@ snapshots: '@sourcegraph/amp-sdk@0.1.0-20251210081226-g90e3892': dependencies: - '@sourcegraph/amp': 0.0.1772802427-gaf6d64 + '@sourcegraph/amp': 0.0.1773030805-g3a9cb3 zod: 3.25.76 '@sourcegraph/amp@0.0.1767830505-ga62310': dependencies: '@napi-rs/keyring': 1.1.9 - '@sourcegraph/amp@0.0.1772802427-gaf6d64': + '@sourcegraph/amp@0.0.1773030805-g3a9cb3': dependencies: '@napi-rs/keyring': 1.1.9 @@ -9773,6 +10022,27 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + obug: 2.1.1 + svelte: 5.53.7 + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.53.7 + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -10003,23 +10273,23 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/chai@5.2.3': dependencies: @@ -10133,6 +10403,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': {} + '@types/turndown@5.0.6': {} '@types/unist@3.0.3': {} @@ -10297,10 +10569,12 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.5.0(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@vercel/analytics@1.5.0(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3))': optionalDependencies: next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 + svelte: 5.53.7 + vue: 3.5.29(typescript@5.9.3) '@vercel/firewall@1.1.1': {} @@ -10320,6 +10594,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vue: 3.5.29(typescript@5.9.3) + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -10362,6 +10642,60 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/reactivity@3.5.29': + dependencies: + '@vue/shared': 3.5.29 + + '@vue/runtime-core@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/runtime-dom@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29(typescript@5.9.3) + + '@vue/shared@3.5.29': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -10373,8 +10707,7 @@ snapshots: acorn@8.15.0: {} - acorn@8.16.0: - optional: true + acorn@8.16.0: {} adm-zip@0.5.16: {} @@ -10453,6 +10786,8 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.1: {} + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -10574,6 +10909,13 @@ snapshots: optionalDependencies: solid-js: 1.9.10 + babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.11): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.5) + optionalDependencies: + solid-js: 1.9.11 + balanced-match@1.0.2: {} baseline-browser-mapping@2.8.18: {} @@ -10964,6 +11306,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -10988,6 +11332,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.6.3: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -11068,6 +11414,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -11533,12 +11881,18 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -12166,6 +12520,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -12219,6 +12577,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -12416,6 +12776,8 @@ snapshots: load-tsconfig@0.2.5: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -12491,6 +12853,10 @@ snapshots: media-typer@1.1.0: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -12547,7 +12913,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.1 @@ -12744,6 +13110,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -13419,8 +13787,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.0(seroval@1.5.0): + dependencies: + seroval: 1.5.0 + seroval@1.3.2: {} + seroval@1.5.0: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -13560,6 +13934,21 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.3(seroval@1.3.2) + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + + solid-refresh@0.6.3(solid-js@1.9.11): + dependencies: + '@babel/generator': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/types': 7.29.0 + solid-js: 1.9.11 + transitivePeerDependencies: + - supports-color + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -13760,6 +14149,25 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.53.7: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.3 + esm-env: 1.2.2 + esrap: 2.2.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + tabbable@6.4.0: {} tailwind-merge@2.6.0: {} @@ -13829,10 +14237,12 @@ snapshots: toidentifier@1.0.1: {} - torph@0.0.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + torph@0.0.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3)): optionalDependencies: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + svelte: 5.53.7 + vue: 3.5.29(typescript@5.9.3) tr46@1.0.1: dependencies: @@ -14233,6 +14643,19 @@ snapshots: - tsx - yaml + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)): + dependencies: + '@babel/core': 7.28.5 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.11) + merge-anything: 5.1.7 + solid-js: 1.9.11 + solid-refresh: 0.6.3(solid-js@1.9.11) + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + transitivePeerDependencies: + - supports-color + vite-plugin-web-extension@4.5.0(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6): dependencies: ajv: 8.17.1 @@ -14278,6 +14701,27 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.27.0 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.37.0 + tsx: 4.20.6 + yaml: 2.8.1 + + vitefu@1.1.2(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)): + optionalDependencies: + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vitest@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -14319,6 +14763,16 @@ snapshots: - tsx - yaml + vue@3.5.29(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.9.3)) + '@vue/shared': 3.5.29 + optionalDependencies: + typescript: 5.9.3 + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -14351,10 +14805,12 @@ snapshots: transitivePeerDependencies: - supports-color - web-haptics@0.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + web-haptics@0.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(svelte@5.53.7)(vue@3.5.29(typescript@5.9.3)): optionalDependencies: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + svelte: 5.53.7 + vue: 3.5.29(typescript@5.9.3) webextension-polyfill@0.10.0: {} @@ -14490,6 +14946,8 @@ snapshots: yoctocolors@2.1.2: {} + zimmerframe@1.1.4: {} + zip-dir@2.0.0: dependencies: async: 3.2.6 From 6d2fd7cb0e3eb15020a2537f70d39bbb947e7553 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 06:51:14 +0000 Subject: [PATCH 10/15] add multi-frame framework stack context fallback Co-authored-by: Aiden Bai --- .../e2e/framework-source-metadata.spec.ts | 26 +++- .../src/utils/get-framework-source-info.ts | 71 +++++++--- .../src/utils/get-solid-source-info.ts | 133 +++++++++++------- .../src/utils/get-svelte-source-info.ts | 66 +++++++-- .../src/utils/get-vue-source-info.ts | 80 ++++++++--- 5 files changed, 277 insertions(+), 99 deletions(-) diff --git a/packages/react-grab/e2e/framework-source-metadata.spec.ts b/packages/react-grab/e2e/framework-source-metadata.spec.ts index 9b1338f9c..05623e200 100644 --- a/packages/react-grab/e2e/framework-source-metadata.spec.ts +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -18,6 +18,7 @@ test.describe("Framework Source Metadata", () => { 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"; @@ -49,6 +50,7 @@ test.describe("Framework Source Metadata", () => { element.$$click = ${String(solidHandler)}; return element; }; + createComponent(AppRoot, { location: "${parentSolidFilePath}:4:1" }); createComponent(template, { location: "${filePath}:14:2" }); `, }, @@ -90,6 +92,7 @@ test.describe("Framework Source Metadata", () => { }); expect(stackContext).toContain(`${solidFilePath}:14:2`); + expect(stackContext).toContain("src/app-root.tsx:4:1"); await reactGrab.activate(); await reactGrab.hoverElement("#solid-metadata-target"); @@ -112,6 +115,7 @@ test.describe("Framework Source Metadata", () => { 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 ({ @@ -179,6 +183,7 @@ test.describe("Framework Source Metadata", () => { 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"; @@ -192,6 +197,13 @@ test.describe("Framework Source Metadata", () => { __file: filePath, __name: "VueFallback", }, + parent: { + type: { + __file: vueParentFilePath, + __name: "AppRoot", + }, + parent: null, + }, }; document.body.appendChild(element); }, @@ -232,6 +244,8 @@ test.describe("Framework Source Metadata", () => { 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 ({ @@ -252,7 +266,14 @@ test.describe("Framework Source Metadata", () => { file: "src/App.svelte", line: 19, column: 4, - parent: null, + parent: { + type: "component", + file: "src/routes/+layout.svelte", + line: 3, + column: 1, + parent: null, + componentTag: "Shell", + }, componentTag: "Counter", }, loc: { @@ -298,5 +319,8 @@ test.describe("Framework Source Metadata", () => { 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/utils/get-framework-source-info.ts b/packages/react-grab/src/utils/get-framework-source-info.ts index c56035e86..1758bd4eb 100644 --- a/packages/react-grab/src/utils/get-framework-source-info.ts +++ b/packages/react-grab/src/utils/get-framework-source-info.ts @@ -3,9 +3,26 @@ import type { ElementStackContextOptions, SourceInfo, } from "../types.js"; -import { getSolidSourceInfo } from "./get-solid-source-info.js"; -import { getSvelteSourceInfo } from "./get-svelte-source-info.js"; -import { getVueSourceInfo } from "./get-vue-source-info.js"; +import { + getSolidSourceInfo, + getSolidStackFrames, +} from "./get-solid-source-info.js"; +import { + getSvelteSourceInfo, + getSvelteStackFrames, +} from "./get-svelte-source-info.js"; +import { getVueSourceInfo, getVueStackFrames } from "./get-vue-source-info.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(":"); +}; const getResolvedFrameworkSourceInfo = async ( element: Element, @@ -26,15 +43,34 @@ const getResolvedFrameworkSourceInfo = async ( return null; }; -const formatSourceLocation = (sourceInfo: ElementSourceInfo): string => { - const locationParts = [sourceInfo.filePath]; - if (sourceInfo.lineNumber !== null) { - locationParts.push(String(sourceInfo.lineNumber)); +const getResolvedFrameworkStackFrames = async ( + element: Element, +): Promise => { + const stackResolvers = [ + getSvelteStackFrames, + getVueStackFrames, + getSolidStackFrames, + ] as const; + + for (const resolveStackFrames of stackResolvers) { + 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; } - if (sourceInfo.columnNumber !== null) { - locationParts.push(String(sourceInfo.columnNumber)); + + return []; +}; + +const formatStackFrame = (stackFrame: ElementSourceInfo): string => { + const sourceLocation = formatSourceLocation(stackFrame); + if (stackFrame.componentName) { + return `\n in ${stackFrame.componentName} (at ${sourceLocation})`; } - return locationParts.join(":"); + return `\n in ${sourceLocation}`; }; export const getFrameworkSourceInfo = ( @@ -65,16 +101,13 @@ export const getFrameworkStackContext = ( element: Element, options: ElementStackContextOptions = {}, ): Promise => - getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { + getResolvedFrameworkStackFrames(element).then((stackFrames) => { const { maxLines = 3 } = options; if (maxLines < 1) return ""; + if (stackFrames.length < 1) return ""; - if (!sourceInfo) return ""; - - const sourceLocation = formatSourceLocation(sourceInfo); - if (sourceInfo.componentName) { - return `\n in ${sourceInfo.componentName} (at ${sourceLocation})`; - } - - return `\n in ${sourceLocation}`; + return stackFrames + .slice(0, maxLines) + .map((stackFrame) => formatStackFrame(stackFrame)) + .join(""); }); diff --git a/packages/react-grab/src/utils/get-solid-source-info.ts b/packages/react-grab/src/utils/get-solid-source-info.ts index 0a79eea60..756694a9c 100644 --- a/packages/react-grab/src/utils/get-solid-source-info.ts +++ b/packages/react-grab/src/utils/get-solid-source-info.ts @@ -16,6 +16,11 @@ interface SolidHandlerSourceMatch { handlerSourceIndex: number; } +interface SolidLocationMatch { + sourceInfo: ElementSourceInfo; + distance: number; +} + const SOLID_RUNTIME_MODULES_GLOBAL_NAME = "__REACT_GRAB_SOLID_RUNTIME_MODULES__"; const SOLID_HANDLER_PREFIX = "$$"; @@ -28,9 +33,9 @@ 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_SOURCE_CACHE = new Map< +const SOLID_HANDLER_STACK_CACHE = new Map< string, - Promise + Promise >(); const SOLID_HANDLER_SOURCE_LENGTH_MIN_CHARS = 3; @@ -132,10 +137,10 @@ const resolveHandlerSourceMatch = async ( return null; }; -const parseNearestLocationLiteral = ( +const parseLocationLiteralsByDistance = ( moduleContent: string, handlerSourceIndex: number, -): { filePath: string; lineNumber: number; columnNumber: number } | null => { +): ElementSourceInfo[] => { const contextWindowStartIndex = Math.max( SOURCE_CONTEXT_WINDOW_START_CHARS, handlerSourceIndex - SOURCE_CONTEXT_HALF_WINDOW_CHARS, @@ -148,13 +153,7 @@ const parseNearestLocationLiteral = ( contextWindowStartIndex, contextWindowEndIndex, ); - - let nearestLocation: { - filePath: string; - lineNumber: number; - columnNumber: number; - } | null = null; - let nearestLocationDistance = Number.POSITIVE_INFINITY; + const locationMatches: SolidLocationMatch[] = []; for (const locationMatch of contextWindowText.matchAll( SOURCE_LOCATION_PATTERN, @@ -168,13 +167,37 @@ const parseNearestLocationLiteral = ( 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[] = []; - if (locationDistance >= nearestLocationDistance) continue; - nearestLocationDistance = locationDistance; - nearestLocation = parsedLocation; + 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 nearestLocation; + return uniqueLocationFrames; }; const toProjectRelativeModulePath = (moduleUrl: string): string | null => { @@ -234,55 +257,61 @@ const findSolidHandlerCandidate = ( return null; }; -const resolveSolidSourceFromHandler = async ( - handlerSource: string, -): Promise => { - const cachedSourceInfo = SOLID_HANDLER_SOURCE_CACHE.get(handlerSource); - if (cachedSourceInfo) return cachedSourceInfo; +const buildGeneratedFrameFromModuleMatch = ( + handlerSourceMatch: SolidHandlerSourceMatch, +): ElementSourceInfo | null => { + const modulePath = toProjectRelativeModulePath(handlerSourceMatch.moduleUrl); + if (!modulePath) return null; - const sourceInfoPromise = (async () => { - const handlerSourceMatch = await resolveHandlerSourceMatch(handlerSource); - if (!handlerSourceMatch) return null; + const generatedLocation = getGeneratedLocationFromModule( + handlerSourceMatch.moduleContent, + handlerSourceMatch.handlerSourceIndex, + ); - const nearestLocationLiteral = parseNearestLocationLiteral( - handlerSourceMatch.moduleContent, - handlerSourceMatch.handlerSourceIndex, - ); - if (nearestLocationLiteral) { - return { - filePath: nearestLocationLiteral.filePath, - lineNumber: nearestLocationLiteral.lineNumber, - columnNumber: nearestLocationLiteral.columnNumber, - componentName: null, - }; - } + return { + filePath: modulePath, + lineNumber: generatedLocation.lineNumber, + columnNumber: generatedLocation.columnNumber, + componentName: null, + }; +}; - const modulePath = toProjectRelativeModulePath( - handlerSourceMatch.moduleUrl, - ); - if (!modulePath) return null; +const resolveSolidStackFramesFromHandler = async ( + handlerSource: string, +): Promise => { + const cachedStackFrames = SOLID_HANDLER_STACK_CACHE.get(handlerSource); + if (cachedStackFrames) return cachedStackFrames; - const generatedLocation = getGeneratedLocationFromModule( + const stackFramesPromise = (async () => { + const handlerSourceMatch = await resolveHandlerSourceMatch(handlerSource); + if (!handlerSourceMatch) return []; + + const locationLiteralStackFrames = parseLocationLiteralsByDistance( handlerSourceMatch.moduleContent, handlerSourceMatch.handlerSourceIndex, ); + if (locationLiteralStackFrames.length > 0) + return locationLiteralStackFrames; - return { - filePath: modulePath, - lineNumber: generatedLocation.lineNumber, - columnNumber: generatedLocation.columnNumber, - componentName: null, - }; + const generatedStackFrame = + buildGeneratedFrameFromModuleMatch(handlerSourceMatch); + if (!generatedStackFrame) return []; + return [generatedStackFrame]; })(); - SOLID_HANDLER_SOURCE_CACHE.set(handlerSource, sourceInfoPromise); - return sourceInfoPromise; + SOLID_HANDLER_STACK_CACHE.set(handlerSource, stackFramesPromise); + return stackFramesPromise; }; -export const getSolidSourceInfo = ( +export const getSolidStackFrames = ( element: Element, -): Promise => { +): Promise => { const solidHandlerCandidate = findSolidHandlerCandidate(element); - if (!solidHandlerCandidate) return Promise.resolve(null); - return resolveSolidSourceFromHandler(solidHandlerCandidate.source); + if (!solidHandlerCandidate) return Promise.resolve([]); + return resolveSolidStackFramesFromHandler(solidHandlerCandidate.source); }; + +export const getSolidSourceInfo = ( + element: Element, +): Promise => + getSolidStackFrames(element).then((stackFrames) => stackFrames[0] ?? null); diff --git a/packages/react-grab/src/utils/get-svelte-source-info.ts b/packages/react-grab/src/utils/get-svelte-source-info.ts index 0c4b97fa1..c6e44d66f 100644 --- a/packages/react-grab/src/utils/get-svelte-source-info.ts +++ b/packages/react-grab/src/utils/get-svelte-source-info.ts @@ -55,19 +55,63 @@ const readComponentNameFromParentMeta = ( return null; }; -export const getSvelteSourceInfo = ( - element: Element, -): ElementSourceInfo | 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 null; + if (!svelteMeta) return []; const sourceLocation = readSvelteLocation(svelteMeta); - if (!sourceLocation) return null; + if (!sourceLocation) return []; - return { - filePath: sourceLocation.filePath, - lineNumber: sourceLocation.lineNumber, - columnNumber: sourceLocation.columnNumber, - componentName: readComponentNameFromParentMeta(svelteMeta), - }; + 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; }; + +export const getSvelteSourceInfo = ( + element: Element, +): ElementSourceInfo | null => getSvelteStackFrames(element)[0] ?? null; diff --git a/packages/react-grab/src/utils/get-vue-source-info.ts b/packages/react-grab/src/utils/get-vue-source-info.ts index fd7db233a..7f4416964 100644 --- a/packages/react-grab/src/utils/get-vue-source-info.ts +++ b/packages/react-grab/src/utils/get-vue-source-info.ts @@ -4,6 +4,7 @@ import { parseSourceLocation } from "./parse-source-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; @@ -26,6 +27,17 @@ const getVueParentComponent = ( 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 => { @@ -52,6 +64,34 @@ const getVueComponentFilePath = ( 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 => { @@ -81,23 +121,31 @@ const resolveFromInspectorAttribute = ( const resolveFromVueRuntimeMetadata = ( element: Element, ): ElementSourceInfo | null => { - const nearestComponent = getNearestVueParentComponent(element); - const nearestComponentType = getVueComponentType(nearestComponent); - const filePath = getVueComponentFilePath(nearestComponentType); - if (!filePath) return null; - - return { - filePath, - lineNumber: null, - columnNumber: null, - componentName: getVueComponentName(nearestComponentType), - }; + const runtimeStackFrames = getVueRuntimeStackFrames(element); + return runtimeStackFrames[0] ?? null; }; -export const getVueSourceInfo = ( - element: Element, -): ElementSourceInfo | null => { +export const getVueStackFrames = (element: Element): ElementSourceInfo[] => { + const combinedStackFrames: ElementSourceInfo[] = []; + const seenFrameIdentities = new Set(); + const inspectorInfo = resolveFromInspectorAttribute(element); - if (inspectorInfo) return inspectorInfo; - return resolveFromVueRuntimeMetadata(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; }; + +export const getVueSourceInfo = (element: Element): ElementSourceInfo | null => + getVueStackFrames(element)[0] ?? resolveFromVueRuntimeMetadata(element); From ddf2e1a00943a74c1e722c84620f19ab8fa46c78 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 07:11:23 +0000 Subject: [PATCH 11/15] merge React and framework stack context lines Co-authored-by: Aiden Bai --- .../e2e/framework-source-metadata.spec.ts | 63 +++++++++++++++++++ packages/react-grab/src/core/index.tsx | 21 +++++-- .../src/utils/merge-stack-context.ts | 32 ++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 packages/react-grab/src/utils/merge-stack-context.ts diff --git a/packages/react-grab/e2e/framework-source-metadata.spec.ts b/packages/react-grab/e2e/framework-source-metadata.spec.ts index 05623e200..7e9b76ca5 100644 --- a/packages/react-grab/e2e/framework-source-metadata.spec.ts +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -11,6 +11,69 @@ const PLACEHOLDER_TARGET_STYLE = { }; 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, }) => { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 1fb188124..cd9d018b3 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -88,6 +88,7 @@ import { parseActivationKey } from "../utils/parse-activation-key.js"; import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; import { openFile } from "../utils/open-file.js"; import { combineBounds } from "../utils/combine-bounds.js"; +import { mergeStackContext } from "../utils/merge-stack-context.js"; import { resolveActionEnabled, resolveToolbarActionEnabled, @@ -563,12 +564,24 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { element: Element, options: ElementStackContextOptions = {}, ): Promise => { - const stackContext = await getStackContext(element, options); - if (stackContext) return stackContext; + const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; + if (maxLines < 1) return ""; + + const reactStackContext = await getStackContext(element, { + ...options, + maxLines, + }); const pluginStackContext = - await pluginRegistry.hooks.resolveElementStackContext(element, options); - return pluginStackContext ?? ""; + await pluginRegistry.hooks.resolveElementStackContext(element, { + ...options, + maxLines, + }); + + if (!reactStackContext) return pluginStackContext ?? ""; + if (!pluginStackContext) return reactStackContext; + + return mergeStackContext(reactStackContext, pluginStackContext, maxLines); }; const isRendererActive = createMemo(() => isActivated() && !isCopying()); 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(""); +}; From 9bc1fd1a607b7c81dfdcaa4f238ccfc2db668dde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 07:30:23 +0000 Subject: [PATCH 12/15] remove framework metadata README sections Co-authored-by: Aiden Bai --- packages/grab/README.md | 12 ------------ packages/react-grab/README.md | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/packages/grab/README.md b/packages/grab/README.md index c339fa305..94f63167a 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -44,18 +44,6 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` -## Framework Source Metadata (Development) - -Grab supports framework metadata fallbacks when React stack traces are unavailable: - -- **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. -- **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). -- **Solid**: uses runtime delegated handler internals (`$$click`, etc.) and scans loaded Vite source modules for the matching handler body, then infers source from nearby compiler location literals. - -When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. - -These metadata channels are development-only and are usually stripped in production builds. - ## Manual Installation If you're using a React framework or build tool, view instructions below: diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 3ce33f7b4..65a25558f 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -44,18 +44,6 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` -## Framework Source Metadata (Development) - -React Grab now supports framework metadata fallbacks when React stack traces are unavailable: - -- **Svelte**: uses `__svelte_meta` (file, line, column) out of the box in dev. -- **Vue**: uses `__vueParentComponent.type.__file` by default (file-level). For line & column metadata, use `vite-plugin-vue-inspector` (`data-v-inspector`). -- **Solid**: uses runtime delegated handler internals (`$$click`, etc.) and scans loaded Vite source modules for the matching handler body, then infers source from nearby compiler location literals. - -When available, these sources are used for selection file paths, copied stack context, `Cmd/Ctrl+O` open-file actions, and `api.getSource` / `api.getStackContext`. - -These metadata channels are development-only and are usually stripped in production builds. - ## Manual Installation If you're using a React framework or build tool, view instructions below: From 1e5ce1766a80deb2c28c0a22a7c60ac2e58920a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 08:00:22 +0000 Subject: [PATCH 13/15] remove unused framework source resolver code Co-authored-by: Aiden Bai --- .../src/utils/get-framework-source-info.ts | 38 +++++-------------- .../src/utils/get-solid-source-info.ts | 5 --- .../src/utils/get-svelte-source-info.ts | 4 -- .../src/utils/get-vue-source-info.ts | 10 ----- 4 files changed, 10 insertions(+), 47 deletions(-) diff --git a/packages/react-grab/src/utils/get-framework-source-info.ts b/packages/react-grab/src/utils/get-framework-source-info.ts index 1758bd4eb..4488bc58d 100644 --- a/packages/react-grab/src/utils/get-framework-source-info.ts +++ b/packages/react-grab/src/utils/get-framework-source-info.ts @@ -3,15 +3,9 @@ import type { ElementStackContextOptions, SourceInfo, } from "../types.js"; -import { - getSolidSourceInfo, - getSolidStackFrames, -} from "./get-solid-source-info.js"; -import { - getSvelteSourceInfo, - getSvelteStackFrames, -} from "./get-svelte-source-info.js"; -import { getVueSourceInfo, getVueStackFrames } from "./get-vue-source-info.js"; +import { getSolidStackFrames } from "./get-solid-source-info.js"; +import { getSvelteStackFrames } from "./get-svelte-source-info.js"; +import { getVueStackFrames } from "./get-vue-source-info.js"; const formatSourceLocation = (sourceInfo: ElementSourceInfo): string => { const locationParts = [sourceInfo.filePath]; @@ -24,25 +18,6 @@ const formatSourceLocation = (sourceInfo: ElementSourceInfo): string => { return locationParts.join(":"); }; -const getResolvedFrameworkSourceInfo = async ( - element: Element, -): Promise => { - const resolvers = [ - getSvelteSourceInfo, - getVueSourceInfo, - getSolidSourceInfo, - ] as const; - - for (const resolveSourceInfo of resolvers) { - const sourceInfo = await resolveSourceInfo(element); - if (!sourceInfo) continue; - if (!sourceInfo.filePath) continue; - return sourceInfo; - } - - return null; -}; - const getResolvedFrameworkStackFrames = async ( element: Element, ): Promise => { @@ -65,6 +40,13 @@ const getResolvedFrameworkStackFrames = async ( return []; }; +const getResolvedFrameworkSourceInfo = ( + element: Element, +): Promise => + getResolvedFrameworkStackFrames(element).then( + (frameworkStackFrames) => frameworkStackFrames[0] ?? null, + ); + const formatStackFrame = (stackFrame: ElementSourceInfo): string => { const sourceLocation = formatSourceLocation(stackFrame); if (stackFrame.componentName) { diff --git a/packages/react-grab/src/utils/get-solid-source-info.ts b/packages/react-grab/src/utils/get-solid-source-info.ts index 756694a9c..0e5de9f30 100644 --- a/packages/react-grab/src/utils/get-solid-source-info.ts +++ b/packages/react-grab/src/utils/get-solid-source-info.ts @@ -310,8 +310,3 @@ export const getSolidStackFrames = ( if (!solidHandlerCandidate) return Promise.resolve([]); return resolveSolidStackFramesFromHandler(solidHandlerCandidate.source); }; - -export const getSolidSourceInfo = ( - element: Element, -): Promise => - getSolidStackFrames(element).then((stackFrames) => stackFrames[0] ?? null); diff --git a/packages/react-grab/src/utils/get-svelte-source-info.ts b/packages/react-grab/src/utils/get-svelte-source-info.ts index c6e44d66f..40ff6edbb 100644 --- a/packages/react-grab/src/utils/get-svelte-source-info.ts +++ b/packages/react-grab/src/utils/get-svelte-source-info.ts @@ -111,7 +111,3 @@ export const getSvelteStackFrames = (element: Element): ElementSourceInfo[] => { return stackFrames; }; - -export const getSvelteSourceInfo = ( - element: Element, -): ElementSourceInfo | null => getSvelteStackFrames(element)[0] ?? null; diff --git a/packages/react-grab/src/utils/get-vue-source-info.ts b/packages/react-grab/src/utils/get-vue-source-info.ts index 7f4416964..276b512f0 100644 --- a/packages/react-grab/src/utils/get-vue-source-info.ts +++ b/packages/react-grab/src/utils/get-vue-source-info.ts @@ -118,13 +118,6 @@ const resolveFromInspectorAttribute = ( }; }; -const resolveFromVueRuntimeMetadata = ( - element: Element, -): ElementSourceInfo | null => { - const runtimeStackFrames = getVueRuntimeStackFrames(element); - return runtimeStackFrames[0] ?? null; -}; - export const getVueStackFrames = (element: Element): ElementSourceInfo[] => { const combinedStackFrames: ElementSourceInfo[] = []; const seenFrameIdentities = new Set(); @@ -146,6 +139,3 @@ export const getVueStackFrames = (element: Element): ElementSourceInfo[] => { return combinedStackFrames; }; - -export const getVueSourceInfo = (element: Element): ElementSourceInfo | null => - getVueStackFrames(element)[0] ?? resolveFromVueRuntimeMetadata(element); From bd8f18878d811a4b43756c12aef52e7c83c7c499 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 08:28:49 +0000 Subject: [PATCH 14/15] consolidate framework playground into single vite app Co-authored-by: Aiden Bai --- .../index.html | 2 +- packages/framework-playground/package.json | 25 ++ packages/framework-playground/src/main.js | 68 +++++ .../src/react-logo-card.react.jsx | 43 +++ .../src/solid-logo-card.solid.jsx | 47 +++ packages/framework-playground/src/styles.css | 82 ++++++ .../src/svelte-logo-card.svelte | 28 ++ .../src/vue-logo-card.vue | 25 ++ packages/framework-playground/vite.config.js | 21 ++ packages/solid-vite-playground/index.html | 12 - packages/solid-vite-playground/package.json | 18 -- .../solid-vite-playground/src/app-root.jsx | 18 -- packages/solid-vite-playground/src/main.jsx | 14 - packages/solid-vite-playground/vite.config.js | 6 - packages/svelte-vite-playground/index.html | 12 - packages/svelte-vite-playground/package.json | 18 -- .../src/app-root.svelte | 15 - packages/svelte-vite-playground/src/main.js | 16 - .../svelte-vite-playground/vite.config.js | 6 - packages/vue-vite-playground/package.json | 18 -- packages/vue-vite-playground/src/app-root.vue | 19 -- packages/vue-vite-playground/src/main.js | 8 - packages/vue-vite-playground/vite.config.js | 6 - pnpm-lock.yaml | 277 ++++++++++++++---- 24 files changed, 563 insertions(+), 241 deletions(-) rename packages/{vue-vite-playground => framework-playground}/index.html (86%) create mode 100644 packages/framework-playground/package.json create mode 100644 packages/framework-playground/src/main.js create mode 100644 packages/framework-playground/src/react-logo-card.react.jsx create mode 100644 packages/framework-playground/src/solid-logo-card.solid.jsx create mode 100644 packages/framework-playground/src/styles.css create mode 100644 packages/framework-playground/src/svelte-logo-card.svelte create mode 100644 packages/framework-playground/src/vue-logo-card.vue create mode 100644 packages/framework-playground/vite.config.js delete mode 100644 packages/solid-vite-playground/index.html delete mode 100644 packages/solid-vite-playground/package.json delete mode 100644 packages/solid-vite-playground/src/app-root.jsx delete mode 100644 packages/solid-vite-playground/src/main.jsx delete mode 100644 packages/solid-vite-playground/vite.config.js delete mode 100644 packages/svelte-vite-playground/index.html delete mode 100644 packages/svelte-vite-playground/package.json delete mode 100644 packages/svelte-vite-playground/src/app-root.svelte delete mode 100644 packages/svelte-vite-playground/src/main.js delete mode 100644 packages/svelte-vite-playground/vite.config.js delete mode 100644 packages/vue-vite-playground/package.json delete mode 100644 packages/vue-vite-playground/src/app-root.vue delete mode 100644 packages/vue-vite-playground/src/main.js delete mode 100644 packages/vue-vite-playground/vite.config.js diff --git a/packages/vue-vite-playground/index.html b/packages/framework-playground/index.html similarity index 86% rename from packages/vue-vite-playground/index.html rename to packages/framework-playground/index.html index 594a56740..eb6d14776 100644 --- a/packages/vue-vite-playground/index.html +++ b/packages/framework-playground/index.html @@ -3,7 +3,7 @@ - Vue Vite Playground + 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..403b65118 --- /dev/null +++ b/packages/framework-playground/src/main.js @@ -0,0 +1,68 @@ +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"); +} + +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 = ` +
+
+

Framework Playground

+

Select each framework logo and verify source mapping.

+
+
+
+
+
+
+
+
+`; + +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..81b454937 --- /dev/null +++ b/packages/framework-playground/src/react-logo-card.react.jsx @@ -0,0 +1,43 @@ +import { useState } from "react"; + +export const ReactLogoCard = () => { + const [selectionCount, setSelectionCount] = useState(0); + + 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..51a6f7b34 --- /dev/null +++ b/packages/framework-playground/src/solid-logo-card.solid.jsx @@ -0,0 +1,47 @@ +import { createSignal } from "solid-js"; + +export const SolidLogoCard = () => { + const [selectionCount, setSelectionCount] = createSignal(0); + + return ( + + ); +}; diff --git a/packages/framework-playground/src/styles.css b/packages/framework-playground/src/styles.css new file mode 100644 index 000000000..70e1e4df1 --- /dev/null +++ b/packages/framework-playground/src/styles.css @@ -0,0 +1,82 @@ +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + Segoe UI, + sans-serif; + color: #111827; + background: #f3f4f6; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.framework-playground { + min-height: 100vh; + padding: 32px; +} + +.framework-playground__header h1 { + margin: 0; + font-size: 32px; +} + +.framework-playground__header p { + margin-top: 8px; + margin-bottom: 24px; + color: #4b5563; +} + +.framework-playground__grid { + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 16px; +} + +.framework-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + background: white; + padding: 18px; + box-shadow: 0 1px 2px rgba(17, 24, 39, 0.08); +} + +.framework-logo-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + width: 100%; + border: 1px solid #d1d5db; + border-radius: 10px; + background: #f9fafb; + padding: 14px; + cursor: pointer; + text-align: left; +} + +.framework-logo-button:hover { + border-color: #9ca3af; +} + +.framework-logo { + width: 72px; + height: 72px; +} + +.framework-logo__name { + font-size: 18px; + font-weight: 600; +} + +.framework-logo__count { + font-size: 13px; + color: #6b7280; +} 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..35207c4ba --- /dev/null +++ b/packages/framework-playground/src/svelte-logo-card.svelte @@ -0,0 +1,28 @@ + + + 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..9532c063d --- /dev/null +++ b/packages/framework-playground/src/vue-logo-card.vue @@ -0,0 +1,25 @@ + + + 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/solid-vite-playground/index.html b/packages/solid-vite-playground/index.html deleted file mode 100644 index f477b492e..000000000 --- a/packages/solid-vite-playground/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Solid Vite Playground - - -
- - - diff --git a/packages/solid-vite-playground/package.json b/packages/solid-vite-playground/package.json deleted file mode 100644 index 1680b1081..000000000 --- a/packages/solid-vite-playground/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@react-grab/solid-vite-playground", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react-grab": "workspace:*", - "solid-js": "latest" - }, - "devDependencies": { - "vite": "latest", - "vite-plugin-solid": "latest" - } -} diff --git a/packages/solid-vite-playground/src/app-root.jsx b/packages/solid-vite-playground/src/app-root.jsx deleted file mode 100644 index da6c1772a..000000000 --- a/packages/solid-vite-playground/src/app-root.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createSignal } from "solid-js"; - -export const AppRoot = () => { - const [counterValue, setCounterValue] = createSignal(0); - - return ( -
-

Solid Runtime Playground

-

Use this app to test React Grab source mapping with Solid.

- -
- ); -}; diff --git a/packages/solid-vite-playground/src/main.jsx b/packages/solid-vite-playground/src/main.jsx deleted file mode 100644 index b516c19b4..000000000 --- a/packages/solid-vite-playground/src/main.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render } from "solid-js/web"; -import { AppRoot } from "./app-root.jsx"; - -if (import.meta.env.DEV) { - import("react-grab"); -} - -const mountElement = document.getElementById("app"); - -if (!mountElement) { - throw new Error("Missing app mount element"); -} - -render(() => , mountElement); diff --git a/packages/solid-vite-playground/vite.config.js b/packages/solid-vite-playground/vite.config.js deleted file mode 100644 index 4a303c3c2..000000000 --- a/packages/solid-vite-playground/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import solid from "vite-plugin-solid"; - -export default defineConfig({ - plugins: [solid()], -}); diff --git a/packages/svelte-vite-playground/index.html b/packages/svelte-vite-playground/index.html deleted file mode 100644 index ceba8e011..000000000 --- a/packages/svelte-vite-playground/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Svelte Vite Playground - - -
- - - diff --git a/packages/svelte-vite-playground/package.json b/packages/svelte-vite-playground/package.json deleted file mode 100644 index c9f72c938..000000000 --- a/packages/svelte-vite-playground/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@react-grab/svelte-vite-playground", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react-grab": "workspace:*", - "svelte": "latest" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "latest", - "vite": "latest" - } -} diff --git a/packages/svelte-vite-playground/src/app-root.svelte b/packages/svelte-vite-playground/src/app-root.svelte deleted file mode 100644 index 1af1222cf..000000000 --- a/packages/svelte-vite-playground/src/app-root.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
-

Svelte Runtime Playground

-

Use this app to test React Grab source mapping with Svelte.

- -
diff --git a/packages/svelte-vite-playground/src/main.js b/packages/svelte-vite-playground/src/main.js deleted file mode 100644 index 87e71e441..000000000 --- a/packages/svelte-vite-playground/src/main.js +++ /dev/null @@ -1,16 +0,0 @@ -import { mount } from "svelte"; -import AppRoot from "./app-root.svelte"; - -if (import.meta.env.DEV) { - import("react-grab"); -} - -const mountElement = document.getElementById("app"); - -if (!mountElement) { - throw new Error("Missing app mount element"); -} - -mount(AppRoot, { - target: mountElement, -}); diff --git a/packages/svelte-vite-playground/vite.config.js b/packages/svelte-vite-playground/vite.config.js deleted file mode 100644 index 8a6f4b5b1..000000000 --- a/packages/svelte-vite-playground/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; - -export default defineConfig({ - plugins: [svelte()], -}); diff --git a/packages/vue-vite-playground/package.json b/packages/vue-vite-playground/package.json deleted file mode 100644 index d743904b6..000000000 --- a/packages/vue-vite-playground/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@react-grab/vue-vite-playground", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react-grab": "workspace:*", - "vue": "latest" - }, - "devDependencies": { - "@vitejs/plugin-vue": "latest", - "vite": "latest" - } -} diff --git a/packages/vue-vite-playground/src/app-root.vue b/packages/vue-vite-playground/src/app-root.vue deleted file mode 100644 index 17345fe2e..000000000 --- a/packages/vue-vite-playground/src/app-root.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/packages/vue-vite-playground/src/main.js b/packages/vue-vite-playground/src/main.js deleted file mode 100644 index 94d9f68e6..000000000 --- a/packages/vue-vite-playground/src/main.js +++ /dev/null @@ -1,8 +0,0 @@ -import { createApp } from "vue"; -import AppRoot from "./app-root.vue"; - -if (import.meta.env.DEV) { - import("react-grab"); -} - -createApp(AppRoot).mount("#app"); diff --git a/packages/vue-vite-playground/vite.config.js b/packages/vue-vite-playground/vite.config.js deleted file mode 100644 index 6ea854713..000000000 --- a/packages/vue-vite-playground/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; - -export default defineConfig({ - plugins: [vue()], -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51045025f..f75223bba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,43 @@ importers: specifier: ^6.0.2 version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + packages/framework-playground: + dependencies: + react: + specifier: latest + version: 19.2.4 + react-dom: + specifier: latest + version: 19.2.4(react@19.2.4) + react-grab: + specifier: workspace:* + version: link:../react-grab + solid-js: + specifier: latest + version: 1.9.11 + svelte: + specifier: latest + version: 5.53.7 + vue: + specifier: latest + version: 3.5.29(typescript@5.9.3) + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: latest + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitejs/plugin-react': + specifier: latest + version: 5.1.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitejs/plugin-vue': + specifier: latest + version: 6.0.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.29(typescript@5.9.3)) + vite: + specifier: latest + version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite-plugin-solid: + specifier: latest + version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + packages/grab: dependencies: '@react-grab/cli': @@ -706,38 +743,6 @@ importers: specifier: ^8.4.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) - packages/solid-vite-playground: - dependencies: - react-grab: - specifier: workspace:* - version: link:../react-grab - solid-js: - specifier: latest - version: 1.9.11 - devDependencies: - vite: - specifier: latest - version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - vite-plugin-solid: - specifier: latest - version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) - - packages/svelte-vite-playground: - dependencies: - react-grab: - specifier: workspace:* - version: link:../react-grab - svelte: - specifier: latest - version: 5.53.7 - devDependencies: - '@sveltejs/vite-plugin-svelte': - specifier: latest - version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) - vite: - specifier: latest - version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - packages/utils: devDependencies: tsup: @@ -790,22 +795,6 @@ importers: specifier: ^6.0.2 version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - packages/vue-vite-playground: - dependencies: - react-grab: - specifier: workspace:* - version: link:../react-grab - vue: - specifier: latest - version: 3.5.29(typescript@5.9.3) - devDependencies: - '@vitejs/plugin-vue': - specifier: latest - version: 6.0.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.29(typescript@5.9.3)) - vite: - specifier: latest - version: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - packages/web-extension: dependencies: react-grab: @@ -991,18 +980,34 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.4': resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1011,6 +1016,10 @@ packages: resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.28.5': resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} engines: {node: '>=6.9.0'} @@ -1033,12 +1042,22 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} @@ -1077,6 +1096,10 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} @@ -1145,10 +1168,18 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.4': resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} @@ -3118,6 +3149,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} cpu: [arm] @@ -3271,8 +3305,8 @@ packages: engines: {node: '>=20'} hasBin: true - '@sourcegraph/amp@0.0.1773030805-g3a9cb3': - resolution: {integrity: sha512-LJQv1F0PC5vZNYn0nzg/i9P2pxVbU6kYWRMjD6t3u4eLa4UTo1Gxb9bTHFpNr/CmHWtRhC3jvx+TK94h+X9y3g==} + '@sourcegraph/amp@0.0.1773043670-gfff8f2': + resolution: {integrity: sha512-RR24DuAqKtLtFMwhK5r3MlmflwIIy4S/9cgYjSsJSKMKN7+3vwtDBTJI0xgXMavf8jE2JjGpIvnJovpElGmjNg==} engines: {node: '>=20'} hasBin: true @@ -3910,6 +3944,12 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-react@5.1.4': + resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-vue@6.0.4': resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6509,6 +6549,11 @@ packages: peerDependencies: react: ^19.2.1 + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6531,6 +6576,10 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -6589,6 +6638,10 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -7851,8 +7904,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.4': {} + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -7873,6 +7934,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -7881,6 +7962,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.5 @@ -7893,6 +7982,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.26.3 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7926,6 +8023,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7935,6 +8039,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.29.0 @@ -7970,6 +8083,11 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 @@ -8001,11 +8119,21 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8040,6 +8168,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -8052,6 +8186,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.4': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -9896,6 +10042,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -10007,14 +10155,14 @@ snapshots: '@sourcegraph/amp-sdk@0.1.0-20251210081226-g90e3892': dependencies: - '@sourcegraph/amp': 0.0.1773030805-g3a9cb3 + '@sourcegraph/amp': 0.0.1773043670-gfff8f2 zod: 3.25.76 '@sourcegraph/amp@0.0.1767830505-ga62310': dependencies: '@napi-rs/keyring': 1.1.9 - '@sourcegraph/amp@0.0.1773030805-g3a9cb3': + '@sourcegraph/amp@0.0.1773043670-gfff8f2': dependencies: '@napi-rs/keyring': 1.1.9 @@ -10272,7 +10420,7 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -10284,7 +10432,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -10594,6 +10742,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.29(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -13450,6 +13610,11 @@ snapshots: react: 19.2.1 scheduler: 0.27.0 + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -13465,6 +13630,8 @@ snapshots: react-refresh@0.17.0: {} + react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.11)(react@19.0.1): dependencies: react: 19.0.1 @@ -13544,6 +13711,8 @@ snapshots: react@19.2.3: {} + react@19.2.4: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 From 07b224c777300bcc354e36acd09bcccd2780d3ed Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Mon, 9 Mar 2026 03:26:11 -0700 Subject: [PATCH 15/15] commmit --- packages/framework-playground/src/main.js | 50 +- .../src/react-logo-card.react.jsx | 9 - .../src/solid-logo-card.solid.jsx | 41 +- packages/framework-playground/src/styles.css | 81 +-- .../src/svelte-logo-card.svelte | 22 +- .../src/vue-logo-card.vue | 19 +- packages/react-grab/src/core/agent/manager.ts | 4 +- packages/react-grab/src/core/context.ts | 541 ++---------------- packages/react-grab/src/core/index.tsx | 93 +-- .../src/core/plugins/framework-source.ts | 36 +- packages/react-grab/src/core/source/index.ts | 67 +++ .../source/parse-location.ts} | 0 packages/react-grab/src/core/source/react.ts | 519 +++++++++++++++++ .../source/solid.ts} | 4 +- .../source/svelte.ts} | 2 +- .../source/vue.ts} | 4 +- packages/react-grab/src/primitives.ts | 24 +- .../src/utils/format-element-stack.ts | 36 ++ .../src/utils/get-framework-source-info.ts | 95 --- packages/website/components/install-tabs.tsx | 4 +- 20 files changed, 827 insertions(+), 824 deletions(-) create mode 100644 packages/react-grab/src/core/source/index.ts rename packages/react-grab/src/{utils/parse-source-location.ts => core/source/parse-location.ts} (100%) create mode 100644 packages/react-grab/src/core/source/react.ts rename packages/react-grab/src/{utils/get-solid-source-info.ts => core/source/solid.ts} (98%) rename packages/react-grab/src/{utils/get-svelte-source-info.ts => core/source/svelte.ts} (98%) rename packages/react-grab/src/{utils/get-vue-source-info.ts => core/source/vue.ts} (97%) create mode 100644 packages/react-grab/src/utils/format-element-stack.ts delete mode 100644 packages/react-grab/src/utils/get-framework-source-info.ts diff --git a/packages/framework-playground/src/main.js b/packages/framework-playground/src/main.js index 403b65118..76c01e177 100644 --- a/packages/framework-playground/src/main.js +++ b/packages/framework-playground/src/main.js @@ -10,7 +10,41 @@ import SvelteLogoCard from "./svelte-logo-card.svelte"; import "./styles.css"; if (import.meta.env.DEV) { - import("react-grab"); + 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"; @@ -31,16 +65,13 @@ if (!applicationMountElement) { applicationMountElement.innerHTML = `
-
-

Framework Playground

-

Select each framework logo and verify source mapping.

-
-
-
-
-
+
+
+
+
+
`; @@ -66,3 +97,4 @@ render( 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 index 81b454937..24cc10cef 100644 --- a/packages/framework-playground/src/react-logo-card.react.jsx +++ b/packages/framework-playground/src/react-logo-card.react.jsx @@ -1,14 +1,9 @@ -import { useState } from "react"; - export const ReactLogoCard = () => { - const [selectionCount, setSelectionCount] = useState(0); - return ( ); }; diff --git a/packages/framework-playground/src/solid-logo-card.solid.jsx b/packages/framework-playground/src/solid-logo-card.solid.jsx index 51a6f7b34..84719d04e 100644 --- a/packages/framework-playground/src/solid-logo-card.solid.jsx +++ b/packages/framework-playground/src/solid-logo-card.solid.jsx @@ -1,47 +1,38 @@ import { createSignal } from "solid-js"; export const SolidLogoCard = () => { - const [selectionCount, setSelectionCount] = createSignal(0); + const [isSelected, setIsSelected] = createSignal(false); return ( ); }; diff --git a/packages/framework-playground/src/styles.css b/packages/framework-playground/src/styles.css index 70e1e4df1..85a729218 100644 --- a/packages/framework-playground/src/styles.css +++ b/packages/framework-playground/src/styles.css @@ -1,82 +1,51 @@ -:root { - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - Segoe UI, - sans-serif; - color: #111827; - background: #f3f4f6; -} - * { box-sizing: border-box; + margin: 0; } body { margin: 0; + background: #000; } .framework-playground { min-height: 100vh; - padding: 32px; -} - -.framework-playground__header h1 { - margin: 0; - font-size: 32px; -} - -.framework-playground__header p { - margin-top: 8px; - margin-bottom: 24px; - color: #4b5563; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 48px; } .framework-playground__grid { - display: grid; - grid-template-columns: repeat(2, minmax(260px, 1fr)); - gap: 16px; -} - -.framework-card { - border: 1px solid #e5e7eb; - border-radius: 12px; - background: white; - padding: 18px; - box-shadow: 0 1px 2px rgba(17, 24, 39, 0.08); + display: flex; + gap: 64px; } .framework-logo-button { display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - width: 100%; - border: 1px solid #d1d5db; - border-radius: 10px; - background: #f9fafb; - padding: 14px; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 0; cursor: pointer; - text-align: left; -} - -.framework-logo-button:hover { - border-color: #9ca3af; } .framework-logo { - width: 72px; - height: 72px; + width: 120px; + height: 120px; } -.framework-logo__name { - font-size: 18px; - font-weight: 600; +.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; } -.framework-logo__count { - font-size: 13px; - color: #6b7280; +.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 index 35207c4ba..578d3d41c 100644 --- a/packages/framework-playground/src/svelte-logo-card.svelte +++ b/packages/framework-playground/src/svelte-logo-card.svelte @@ -1,28 +1,20 @@ - - diff --git a/packages/framework-playground/src/vue-logo-card.vue b/packages/framework-playground/src/vue-logo-card.vue index 9532c063d..f29de1d01 100644 --- a/packages/framework-playground/src/vue-logo-card.vue +++ b/packages/framework-playground/src/vue-logo-card.vue @@ -1,25 +1,8 @@ - - 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 cd9d018b3..cbdb1297b 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -24,14 +24,18 @@ import { waitUntilNextFrame, } from "../utils/native-raf.js"; import { ReactGrabRenderer } from "../components/renderer.js"; +import { checkIsNextProject } from "./context.js"; import { - getStack, - getStackContext, - getNearestComponentName, - getComponentDisplayName, - resolveSourceFromStack, - checkIsNextProject, -} from "./context.js"; + 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"; @@ -88,7 +92,6 @@ import { parseActivationKey } from "../utils/parse-activation-key.js"; import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; import { openFile } from "../utils/open-file.js"; import { combineBounds } from "../utils/combine-bounds.js"; -import { mergeStackContext } from "../utils/merge-stack-context.js"; import { resolveActionEnabled, resolveToolbarActionEnabled, @@ -110,8 +113,6 @@ import type { PerformWithFeedbackOptions, SettableOptions, SourceInfo, - ElementSourceInfo, - ElementStackContextOptions, Plugin, ToolbarState, HistoryItem, @@ -530,60 +531,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => isDragging(), ); - const resolveElementSourceInfo = async ( - element: Element, - ): Promise => { - const stack = await getStack(element); - const sourceFromStack = resolveSourceFromStack(stack); - if (sourceFromStack) { - return { - filePath: sourceFromStack.filePath, - lineNumber: sourceFromStack.lineNumber ?? null, - columnNumber: null, - componentName: sourceFromStack.componentName, - }; - } - - return pluginRegistry.hooks.resolveElementSource(element); - }; - - const resolveElementComponentName = async ( - element: Element, - ): Promise => { - const sourceComponentName = await getNearestComponentName(element); - if (sourceComponentName) return sourceComponentName; - - const pluginComponentName = - await pluginRegistry.hooks.resolveElementComponentName(element); - if (pluginComponentName) return pluginComponentName; - - return getComponentDisplayName(element); - }; - - const resolveElementStackContext = async ( - element: Element, - options: ElementStackContextOptions = {}, - ): Promise => { - const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - if (maxLines < 1) return ""; - - const reactStackContext = await getStackContext(element, { - ...options, - maxLines, - }); - - const pluginStackContext = - await pluginRegistry.hooks.resolveElementStackContext(element, { - ...options, - maxLines, - }); - - if (!reactStackContext) return pluginStackContext ?? ""; - if (!pluginStackContext) return reactStackContext; - - return mergeStackContext(reactStackContext, pluginStackContext, maxLines); - }; - const isRendererActive = createMemo(() => isActivated() && !isCopying()); const crosshairVisible = createMemo( @@ -4308,8 +4255,22 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext: (element: Element) => - resolveElementStackContext(element), + 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/plugins/framework-source.ts b/packages/react-grab/src/core/plugins/framework-source.ts index 0c3591831..219d14fdc 100644 --- a/packages/react-grab/src/core/plugins/framework-source.ts +++ b/packages/react-grab/src/core/plugins/framework-source.ts @@ -1,21 +1,39 @@ 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 { - getFrameworkComponentName, - getFrameworkSourceInfo, - getFrameworkStackContext, -} from "../../utils/get-framework-source-info.js"; + resolveElementSourceInfo, + resolveElementComponentName, + resolveElementStack, +} from "../source/index.js"; export const frameworkSourcePlugin: Plugin = { name: "framework-source", hooks: { - resolveElementSource: (element) => getFrameworkSourceInfo(element), + resolveElementSource: (element) => resolveElementSourceInfo(element), resolveElementComponentName: (element) => - getFrameworkComponentName(element), - resolveElementStackContext: (element, options) => - getFrameworkStackContext(element, options), + 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 stackContext = await getFrameworkStackContext(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/utils/parse-source-location.ts b/packages/react-grab/src/core/source/parse-location.ts similarity index 100% rename from packages/react-grab/src/utils/parse-source-location.ts rename to packages/react-grab/src/core/source/parse-location.ts 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/utils/get-solid-source-info.ts b/packages/react-grab/src/core/source/solid.ts similarity index 98% rename from packages/react-grab/src/utils/get-solid-source-info.ts rename to packages/react-grab/src/core/source/solid.ts index 0e5de9f30..c0763c942 100644 --- a/packages/react-grab/src/utils/get-solid-source-info.ts +++ b/packages/react-grab/src/core/source/solid.ts @@ -1,5 +1,5 @@ -import type { ElementSourceInfo } from "../types.js"; -import { parseSourceLocation } from "./parse-source-location.js"; +import type { ElementSourceInfo } from "../../types.js"; +import { parseSourceLocation } from "./parse-location.js"; interface SolidRuntimeModuleRecord { url: string; diff --git a/packages/react-grab/src/utils/get-svelte-source-info.ts b/packages/react-grab/src/core/source/svelte.ts similarity index 98% rename from packages/react-grab/src/utils/get-svelte-source-info.ts rename to packages/react-grab/src/core/source/svelte.ts index 40ff6edbb..98326a2f3 100644 --- a/packages/react-grab/src/utils/get-svelte-source-info.ts +++ b/packages/react-grab/src/core/source/svelte.ts @@ -1,4 +1,4 @@ -import type { ElementSourceInfo } from "../types.js"; +import type { ElementSourceInfo } from "../../types.js"; const SVELTE_META_PROPERTY_NAME = "__svelte_meta"; const SVELTE_COLUMN_OFFSET = 1; diff --git a/packages/react-grab/src/utils/get-vue-source-info.ts b/packages/react-grab/src/core/source/vue.ts similarity index 97% rename from packages/react-grab/src/utils/get-vue-source-info.ts rename to packages/react-grab/src/core/source/vue.ts index 276b512f0..2aca82a88 100644 --- a/packages/react-grab/src/utils/get-vue-source-info.ts +++ b/packages/react-grab/src/core/source/vue.ts @@ -1,5 +1,5 @@ -import type { ElementSourceInfo } from "../types.js"; -import { parseSourceLocation } from "./parse-source-location.js"; +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}]`; 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/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/get-framework-source-info.ts b/packages/react-grab/src/utils/get-framework-source-info.ts deleted file mode 100644 index 4488bc58d..000000000 --- a/packages/react-grab/src/utils/get-framework-source-info.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { - ElementSourceInfo, - ElementStackContextOptions, - SourceInfo, -} from "../types.js"; -import { getSolidStackFrames } from "./get-solid-source-info.js"; -import { getSvelteStackFrames } from "./get-svelte-source-info.js"; -import { getVueStackFrames } from "./get-vue-source-info.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(":"); -}; - -const getResolvedFrameworkStackFrames = async ( - element: Element, -): Promise => { - const stackResolvers = [ - getSvelteStackFrames, - getVueStackFrames, - getSolidStackFrames, - ] as const; - - for (const resolveStackFrames of stackResolvers) { - 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 []; -}; - -const getResolvedFrameworkSourceInfo = ( - element: Element, -): Promise => - getResolvedFrameworkStackFrames(element).then( - (frameworkStackFrames) => frameworkStackFrames[0] ?? null, - ); - -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 getFrameworkSourceInfo = ( - element: Element, -): Promise => getResolvedFrameworkSourceInfo(element); - -export const getFrameworkSourceInfoForApi = ( - element: Element, -): Promise => - getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { - if (!sourceInfo) return null; - return { - filePath: sourceInfo.filePath, - lineNumber: sourceInfo.lineNumber, - componentName: sourceInfo.componentName, - }; - }); - -export const getFrameworkComponentName = ( - element: Element, -): Promise => - getResolvedFrameworkSourceInfo(element).then((sourceInfo) => { - if (!sourceInfo) return null; - return sourceInfo.componentName; - }); - -export const getFrameworkStackContext = ( - element: Element, - options: ElementStackContextOptions = {}, -): Promise => - getResolvedFrameworkStackFrames(element).then((stackFrames) => { - const { maxLines = 3 } = options; - if (maxLines < 1) return ""; - if (stackFrames.length < 1) return ""; - - return stackFrames - .slice(0, maxLines) - .map((stackFrame) => formatStackFrame(stackFrame)) - .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 && (