diff --git a/packages/bundler-plugins/src/babel-plugin/component-annotation.ts b/packages/bundler-plugins/src/babel-plugin/component-annotation.ts new file mode 100644 index 00000000..ae446a9c --- /dev/null +++ b/packages/bundler-plugins/src/babel-plugin/component-annotation.ts @@ -0,0 +1,65 @@ +import { DEFAULT_IGNORED_ELEMENTS } from "./constants"; + +export const WEB_COMPONENT_NAME = "data-sentry-component"; +export const WEB_ELEMENT_NAME = "data-sentry-element"; +export const WEB_SOURCE_FILE_NAME = "data-sentry-source-file"; + +export const NATIVE_COMPONENT_NAME = "dataSentryComponent"; +export const NATIVE_ELEMENT_NAME = "dataSentryElement"; +export const NATIVE_SOURCE_FILE_NAME = "dataSentrySourceFile"; + +export type ComponentAnnotationAttributeNames = readonly [string, string, string]; + +export type ComponentAnnotationAttribute = [string, string]; + +type ComponentAnnotationAttributesInput = { + attributeNames: ComponentAnnotationAttributeNames; + componentName: string; + elementName: string; + existingAttributes: ReadonlySet; + ignoredComponents: readonly string[]; + isFragment: boolean; + sourceFileName?: string; +}; + +const DEFAULT_IGNORED_ELEMENTS_SET = new Set(DEFAULT_IGNORED_ELEMENTS); + +export function getComponentAnnotationAttributes({ + attributeNames, + componentName, + elementName, + existingAttributes, + ignoredComponents, + isFragment, + sourceFileName, +}: ComponentAnnotationAttributesInput): ComponentAnnotationAttribute[] { + if ( + isFragment || + ignoredComponents.includes(componentName) || + ignoredComponents.includes(elementName) + ) { + return []; + } + + const [componentAttributeName, elementAttributeName, sourceFileAttributeName] = attributeNames; + const isIgnoredElement = DEFAULT_IGNORED_ELEMENTS_SET.has(elementName); + const attributes: ComponentAnnotationAttribute[] = []; + + if (!isIgnoredElement && !existingAttributes.has(elementAttributeName)) { + attributes.push([elementAttributeName, elementName]); + } + + if (componentName && !existingAttributes.has(componentAttributeName)) { + attributes.push([componentAttributeName, componentName]); + } + + if ( + sourceFileName && + (componentName || !isIgnoredElement) && + !existingAttributes.has(sourceFileAttributeName) + ) { + attributes.push([sourceFileAttributeName, sourceFileName]); + } + + return attributes; +} diff --git a/packages/bundler-plugins/src/babel-plugin/index.ts b/packages/bundler-plugins/src/babel-plugin/index.ts index 13773f43..8bb46a76 100644 --- a/packages/bundler-plugins/src/babel-plugin/index.ts +++ b/packages/bundler-plugins/src/babel-plugin/index.ts @@ -36,15 +36,17 @@ import type * as Babel from "@babel/core"; import type { PluginObj, PluginPass } from "@babel/core"; -import { DEFAULT_IGNORED_ELEMENTS, KNOWN_INCOMPATIBLE_PLUGINS } from "./constants"; - -const webComponentName = "data-sentry-component"; -const webElementName = "data-sentry-element"; -const webSourceFileName = "data-sentry-source-file"; - -const nativeComponentName = "dataSentryComponent"; -const nativeElementName = "dataSentryElement"; -const nativeSourceFileName = "dataSentrySourceFile"; +import { + getComponentAnnotationAttributes, + NATIVE_COMPONENT_NAME, + NATIVE_ELEMENT_NAME, + NATIVE_SOURCE_FILE_NAME, + WEB_COMPONENT_NAME, + WEB_ELEMENT_NAME, + WEB_SOURCE_FILE_NAME, + type ComponentAnnotationAttributeNames, +} from "./component-annotation"; +import { KNOWN_INCOMPATIBLE_PLUGINS } from "./constants"; const SENTRY_LABEL_ATTRIBUTE = "sentry-label"; const MAX_LABEL_LENGTH = 64; @@ -86,7 +88,7 @@ interface JSXProcessingContext { /** Source file name (optional) */ sourceFileName?: string; /** Array of attribute names [component, element, sourceFile] */ - attributeNames: string[]; + attributeNames: ComponentAnnotationAttributeNames; /** Array of component names to ignore */ ignoredComponents: string[]; /** Fragment context for identifying React fragments */ @@ -344,69 +346,27 @@ function applyAttributes( componentName: string ): void { const { t, attributeNames, ignoredComponents, fragmentContext, sourceFileName } = context; - const [componentAttributeName, elementAttributeName, sourceFileAttributeName] = attributeNames; // e.g., Raw JSX text like the `A` in `

a

` if (!openingElement.node) { return; } - // Check if this is a React fragment - if so, skip attribute addition entirely - const isFragment = isReactFragment(t, openingElement, fragmentContext); - if (isFragment) { - return; - } - if (!openingElement.node.attributes) openingElement.node.attributes = []; const elementName = getPathName(t, openingElement); - const isAnIgnoredComponent = ignoredComponents.some( - (ignoredComponent) => ignoredComponent === componentName || ignoredComponent === elementName - ); - - // Add a stable attribute for the element name but only for non-DOM names - let isAnIgnoredElement = false; - if (!isAnIgnoredComponent && !hasAttributeWithName(openingElement, elementAttributeName)) { - if (DEFAULT_IGNORED_ELEMENTS.includes(elementName)) { - isAnIgnoredElement = true; - } else { - // Always add element attribute for non-ignored elements - if (elementAttributeName) { - openingElement.node.attributes.push( - t.jSXAttribute(t.jSXIdentifier(elementAttributeName), t.stringLiteral(elementName)) - ); - } - } - } - - // Add a stable attribute for the component name (absent for non-root elements) - if ( - componentName && - !isAnIgnoredComponent && - !hasAttributeWithName(openingElement, componentAttributeName) - ) { - if (componentAttributeName) { - openingElement.node.attributes.push( - t.jSXAttribute(t.jSXIdentifier(componentAttributeName), t.stringLiteral(componentName)) - ); - } - } - - // Add a stable attribute for the source file name - // Updated condition: add source file for elements that have either: - // 1. A component name (root elements), OR - // 2. An element name that's not ignored (child elements) - if ( - sourceFileName && - !isAnIgnoredComponent && - (componentName || !isAnIgnoredElement) && - !hasAttributeWithName(openingElement, sourceFileAttributeName) - ) { - if (sourceFileAttributeName) { - openingElement.node.attributes.push( - t.jSXAttribute(t.jSXIdentifier(sourceFileAttributeName), t.stringLiteral(sourceFileName)) - ); - } + for (const [name, value] of getComponentAnnotationAttributes({ + attributeNames, + componentName, + elementName, + existingAttributes: getExistingAttributeNames(openingElement), + ignoredComponents, + isFragment: isReactFragment(t, openingElement, fragmentContext), + sourceFileName, + })) { + openingElement.node.attributes.push( + t.jSXAttribute(t.jSXIdentifier(name), t.stringLiteral(value)) + ); } } @@ -455,12 +415,12 @@ function isKnownIncompatiblePluginFromState(state: AnnotationPluginPass): boolea }); } -function attributeNamesFromState(state: AnnotationPluginPass): [string, string, string] { +function attributeNamesFromState(state: AnnotationPluginPass): ComponentAnnotationAttributeNames { if (state.opts.native) { - return [nativeComponentName, nativeElementName, nativeSourceFileName]; + return [NATIVE_COMPONENT_NAME, NATIVE_ELEMENT_NAME, NATIVE_SOURCE_FILE_NAME]; } - return [webComponentName, webElementName, webSourceFileName]; + return [WEB_COMPONENT_NAME, WEB_ELEMENT_NAME, WEB_SOURCE_FILE_NAME]; } function collectFragmentContext(programPath: Babel.NodePath): FragmentContext { @@ -619,21 +579,18 @@ function isReactFragment( return false; } -function hasAttributeWithName( - openingElement: Babel.NodePath, - name: string | undefined | null -): boolean { - if (!name) { - return false; - } +function getExistingAttributeNames( + openingElement: Babel.NodePath +): Set { + const names = new Set(); - return openingElement.node.attributes.some((node) => { - if (node.type === "JSXAttribute") { - return node.name.name === name; + openingElement.node.attributes.forEach((node) => { + if (node.type === "JSXAttribute" && typeof node.name.name === "string") { + names.add(node.name.name); } - - return false; }); + + return names; } function getPathName(t: typeof Babel.types, path: Babel.NodePath): string { diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-ast.ts b/packages/bundler-plugins/src/core/component-annotation-vite-ast.ts new file mode 100644 index 00000000..9c592d45 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-ast.ts @@ -0,0 +1,91 @@ +import type { SourceMap } from "magic-string"; + +import type { ComponentAnnotationAttribute } from "../babel-plugin/component-annotation"; + +export type AstNode = { + type: string; + start?: number; + end?: number; + [key: string]: unknown; +}; + +export type JSXElementNode = AstNode & { + type: "JSXElement"; + openingElement: JSXOpeningElementNode; + children?: AstNode[]; +}; + +export type JSXFragmentNode = AstNode & { + type: "JSXFragment"; + children?: AstNode[]; +}; + +export type JSXRootNode = JSXElementNode | JSXFragmentNode; + +export type JSXOpeningElementNode = AstNode & { + type: "JSXOpeningElement"; + name: AstNode; + attributes?: AstNode[]; + selfClosing?: boolean; +}; + +export type FragmentContext = { + fragmentAliases: Set; + reactNamespaceAliases: Set; +}; + +export type AttributeInsertion = { + offset: number; + attributes: ComponentAnnotationAttribute[]; +}; + +export type ParseAstAsync = (code: string, options: { lang: "jsx" | "tsx" }) => Promise; + +export type MagicStringLike = { + appendLeft(offset: number, content: string): void; + toString(): string; + generateMap?(options: { + file?: string; + source?: string; + includeContent?: boolean; + hires?: boolean | "boundary"; + }): SourceMap | string; +}; + +export type ComponentAnnotationTransformMeta = { + magicString?: MagicStringLike; +}; + +export type ComponentAnnotationTransformResult = + | { + code: string; + map?: SourceMap | string; + } + | null + | undefined; + +export function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isAstNode(value: unknown): value is AstNode { + return isObject(value) && typeof value.type === "string"; +} + +export function walkAst(node: unknown, visit: (node: AstNode) => void): void { + if (!isAstNode(node)) { + return; + } + + visit(node); + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) { + walkAst(child, visit); + } + } else if (isAstNode(value)) { + walkAst(value, visit); + } + } +} diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts b/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts new file mode 100644 index 00000000..065438cc --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts @@ -0,0 +1,143 @@ +import type { AstNode, FragmentContext } from "./component-annotation-vite-ast"; +import { isAstNode, isObject, walkAst } from "./component-annotation-vite-ast"; +import { getStringName } from "./component-annotation-vite-jsx"; + +export function collectFragmentContext(ast: AstNode): FragmentContext { + const context: FragmentContext = { + fragmentAliases: new Set(), + reactNamespaceAliases: new Set(["React"]), + }; + + walkAst(ast, (node) => { + collectFragmentAliasesFromImport(node, context); + collectFragmentAliasesFromVariableDeclarator(node, context); + }); + + return context; +} + +function collectFragmentAliasesFromImport(node: AstNode, context: FragmentContext): void { + if (node.type !== "ImportDeclaration" || !isObject(node.source)) { + return; + } + + const source = node.source.value; + if (source !== "react" && source !== "React") { + return; + } + + const specifiers = Array.isArray(node.specifiers) ? node.specifiers : []; + + for (const specifier of specifiers) { + if (!isAstNode(specifier) || !isObject(specifier.local)) { + continue; + } + + const localName = getStringName(specifier.local); + if (!localName) { + continue; + } + + if ( + specifier.type === "ImportDefaultSpecifier" || + specifier.type === "ImportNamespaceSpecifier" + ) { + context.reactNamespaceAliases.add(localName); + } else if (isImportedReactFragment(specifier)) { + context.fragmentAliases.add(localName); + } + } +} + +function collectFragmentAliasesFromVariableDeclarator( + node: AstNode, + context: FragmentContext +): void { + if (node.type !== "VariableDeclarator" || !isObject(node.id) || !isObject(node.init)) { + return; + } + + if (node.id.type === "Identifier") { + collectFragmentAliasFromIdentifier(node.id, node.init, context); + return; + } + + if (node.id.type === "ObjectPattern") { + collectFragmentAliasFromObjectPattern(node.id, node.init, context); + } +} + +function collectFragmentAliasFromIdentifier( + id: Record, + init: Record, + context: FragmentContext +): void { + const localName = getStringName(id); + if (!localName) { + return; + } + + if (init.type === "Identifier" && context.fragmentAliases.has(getStringName(init) ?? "")) { + context.fragmentAliases.add(localName); + } + + if (isReactFragmentMemberExpression(init, context)) { + context.fragmentAliases.add(localName); + } +} + +function collectFragmentAliasFromObjectPattern( + id: Record, + init: Record, + context: FragmentContext +): void { + if ( + init.type !== "Identifier" || + !context.reactNamespaceAliases.has(getStringName(init) ?? "") || + !Array.isArray(id.properties) + ) { + return; + } + + for (const property of id.properties) { + if (!isFragmentObjectPatternProperty(property)) { + continue; + } + + const localName = getStringName(property.value); + if (localName) { + context.fragmentAliases.add(localName); + } + } +} + +function isImportedReactFragment(specifier: AstNode): boolean { + return ( + specifier.type === "ImportSpecifier" && + isObject(specifier.imported) && + getStringName(specifier.imported) === "Fragment" + ); +} + +function isReactFragmentMemberExpression( + init: Record, + context: FragmentContext +): boolean { + return ( + init.type === "MemberExpression" && + isObject(init.object) && + isObject(init.property) && + context.reactNamespaceAliases.has(getStringName(init.object) ?? "") && + getStringName(init.property) === "Fragment" + ); +} + +function isFragmentObjectPatternProperty(property: unknown): property is { value: unknown } { + return ( + isObject(property) && + (property.type === "Property" || property.type === "ObjectProperty") && + isObject(property.key) && + getStringName(property.key) === "Fragment" && + isObject(property.value) + ); +} diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts b/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts new file mode 100644 index 00000000..7b1653e2 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts @@ -0,0 +1,207 @@ +import { + getComponentAnnotationAttributes, + WEB_COMPONENT_NAME, + WEB_ELEMENT_NAME, + WEB_SOURCE_FILE_NAME, +} from "../babel-plugin/component-annotation"; +import type { ComponentAnnotationAttribute } from "../babel-plugin/component-annotation"; +import type { + AttributeInsertion, + FragmentContext, + JSXElementNode, + JSXFragmentNode, + JSXOpeningElementNode, + JSXRootNode, +} from "./component-annotation-vite-ast"; +import { isAstNode, isObject } from "./component-annotation-vite-ast"; + +const UNKNOWN_ELEMENT_NAME = "unknown"; +const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const; +const WEB_ATTRIBUTE_NAME_SET = new Set(WEB_ATTRIBUTE_NAMES); + +type PendingInsertion = { + offset: number; + attributeValues: Map; +}; + +export function isJSXElement(value: unknown): value is JSXElementNode { + return isAstNode(value) && value.type === "JSXElement"; +} + +export function isJSXFragment(value: unknown): value is JSXFragmentNode { + return isAstNode(value) && value.type === "JSXFragment"; +} + +export function isJSXRoot(value: unknown): value is JSXRootNode { + return isJSXElement(value) || isJSXFragment(value); +} + +export function getStringName(node: unknown): string | null { + return isObject(node) && typeof node.name === "string" ? node.name : null; +} + +export function getJSXName(name: unknown): string { + if (!isAstNode(name)) { + return UNKNOWN_ELEMENT_NAME; + } + + if (name.type === "JSXIdentifier") { + return getStringName(name) ?? UNKNOWN_ELEMENT_NAME; + } + + if (name.type === "JSXNamespacedName") { + return getStringName(name.name) ?? UNKNOWN_ELEMENT_NAME; + } + + if (name.type === "JSXMemberExpression") { + const objectName = getJSXName(name.object); + const propertyName = getJSXName(name.property); + + return `${objectName}.${propertyName}`; + } + + return UNKNOWN_ELEMENT_NAME; +} + +export function getInsertionOffset( + code: string, + openingElement: JSXOpeningElementNode +): number | null { + if (typeof openingElement.end !== "number") { + return null; + } + + if (!openingElement.selfClosing) { + return openingElement.end - 1; + } + + let offset = openingElement.end - 2; + + while (offset > 0 && /\s/.test(code[offset] ?? "")) { + offset -= 1; + } + + return code[offset] === "/" ? offset : openingElement.end - 1; +} + +export function isReactFragment( + openingElement: JSXOpeningElementNode, + fragmentContext: FragmentContext +): boolean { + const elementName = getJSXName(openingElement.name); + + if (elementName === "Fragment" || elementName === "React.Fragment") { + return true; + } + + if (fragmentContext.fragmentAliases.has(elementName)) { + return true; + } + + if (isObject(openingElement.name) && openingElement.name.type === "JSXMemberExpression") { + const objectName = getJSXName(openingElement.name.object); + const propertyName = getJSXName(openingElement.name.property); + + return ( + propertyName === "Fragment" && + (fragmentContext.reactNamespaceAliases.has(objectName) || + fragmentContext.fragmentAliases.has(objectName)) + ); + } + + return false; +} + +export function addPendingAttributes( + code: string, + openingElement: JSXOpeningElementNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + sourceFileName: string, + insertionsByOffset: Map +): void { + const offset = getInsertionOffset(code, openingElement); + if (offset === null) { + return; + } + + const pendingInsertion = insertionsByOffset.get(offset); + const existingAttributes = getExistingAttributeNames(openingElement); + + for (const attributeName of pendingInsertion?.attributeValues.keys() ?? []) { + existingAttributes.add(attributeName); + } + + const attributes = getComponentAnnotationAttributes({ + attributeNames: [WEB_COMPONENT_NAME, WEB_ELEMENT_NAME, WEB_SOURCE_FILE_NAME], + componentName, + elementName: getJSXName(openingElement.name), + existingAttributes, + ignoredComponents, + isFragment: isReactFragment(openingElement, fragmentContext), + sourceFileName, + }); + + if (attributes.length === 0) { + return; + } + + const insertion = + pendingInsertion ?? + insertionsByOffset + .set(offset, { + offset, + attributeValues: new Map(), + }) + .get(offset); + + for (const [name, value] of attributes) { + insertion?.attributeValues.set(name, value); + } +} + +export function toAttributeInsertions( + insertionsByOffset: Map +): AttributeInsertion[] { + return [...insertionsByOffset.values()].map(({ offset, attributeValues }) => ({ + offset, + attributes: getOrderedAttributes(attributeValues), + })); +} + +function getExistingAttributeNames(openingElement: JSXOpeningElementNode): Set { + const names = new Set(); + + for (const attribute of openingElement.attributes ?? []) { + if (attribute.type === "JSXAttribute") { + const name = getStringName(attribute.name); + if (name) { + names.add(name); + } + } + } + + return names; +} + +function getOrderedAttributes( + attributeValues: ReadonlyMap +): ComponentAnnotationAttribute[] { + const attributes: ComponentAnnotationAttribute[] = []; + + for (const name of WEB_ATTRIBUTE_NAMES) { + const value = attributeValues.get(name); + if (value) { + attributes.push([name, value]); + } + } + + for (const [name, value] of attributeValues) { + if (!WEB_ATTRIBUTE_NAME_SET.has(name)) { + attributes.push([name, value]); + } + } + + return attributes; +} diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts b/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts new file mode 100644 index 00000000..1fea3fd2 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts @@ -0,0 +1,200 @@ +import type { + AstNode, + AttributeInsertion, + FragmentContext, + JSXRootNode, +} from "./component-annotation-vite-ast"; +import { + addPendingAttributes, + getStringName, + isJSXElement, + isJSXRoot, + toAttributeInsertions, +} from "./component-annotation-vite-jsx"; +import { isAstNode, isObject, walkAst } from "./component-annotation-vite-ast"; +import { collectFragmentContext } from "./component-annotation-vite-fragments"; + +type ComponentJSXRoots = { name: string; roots: JSXRootNode[] }; + +export function collectViteComponentAnnotationInsertions( + code: string, + ast: AstNode, + ignoredComponents: string[], + sourceFileName: string +): AttributeInsertion[] { + const fragmentContext = collectFragmentContext(ast); + const components = collectComponentJSXRoots(ast); + const insertionsByOffset = new Map< + number, + { offset: number; attributeValues: Map } + >(); + + for (const component of components) { + for (const root of component.roots) { + processJSX( + code, + root, + component.name, + ignoredComponents, + fragmentContext, + sourceFileName, + insertionsByOffset + ); + } + } + + return toAttributeInsertions(insertionsByOffset); +} + +function getJSXRootsFromReturnArgument(argument: unknown): JSXRootNode[] { + if (isJSXRoot(argument)) { + return [argument]; + } + + if (isObject(argument) && argument.type === "ConditionalExpression") { + return [argument.consequent, argument.alternate].filter(isJSXRoot); + } + + return []; +} + +function getReturnedJSXFromFunction(functionNode: AstNode): JSXRootNode[] { + const body = functionNode.body; + + if (isJSXRoot(body)) { + return [body]; + } + + if (!isObject(body) || body.type !== "BlockStatement") { + return []; + } + + const bodyStatements = Array.isArray(body.body) ? body.body : []; + const returnStatement = bodyStatements.find((statement) => { + return isAstNode(statement) && statement.type === "ReturnStatement"; + }); + + return isObject(returnStatement) ? getJSXRootsFromReturnArgument(returnStatement.argument) : []; +} + +function pushFunctionComponent( + components: ComponentJSXRoots[], + nameNode: unknown, + functionNode: AstNode +): void { + const name = getStringName(nameNode); + + if (name) { + components.push({ + name, + roots: getReturnedJSXFromFunction(functionNode), + }); + } +} + +function collectComponentJSXRoots(ast: AstNode): ComponentJSXRoots[] { + const components: ComponentJSXRoots[] = []; + + walkAst(ast, (node) => { + if (node.type === "FunctionDeclaration" && isObject(node.id)) { + pushFunctionComponent(components, node.id, node); + return; + } + + if (node.type === "VariableDeclarator" && isObject(node.id)) { + if (isAstNode(node.init) && node.init.type === "ArrowFunctionExpression") { + pushFunctionComponent(components, node.id, node.init); + } + + return; + } + + if (node.type === "ClassDeclaration") { + pushClassComponent(components, node); + } + }); + + return components; +} + +function pushClassComponent(components: ComponentJSXRoots[], node: AstNode): void { + const renderMethodBody = getClassRenderMethodBody(node); + + if (!renderMethodBody) { + return; + } + + const roots: JSXRootNode[] = []; + + walkAst(renderMethodBody, (child) => { + if (child.type === "ReturnStatement" && isJSXRoot(child.argument)) { + roots.push(child.argument); + } + }); + + components.push({ + name: getStringName(node.id) ?? "", + roots, + }); +} + +function getClassRenderMethodBody(node: AstNode): AstNode | null { + if (!isObject(node.body) || !Array.isArray(node.body.body)) { + return null; + } + + const renderMethod = node.body.body.find((member) => { + return ( + isObject(member) && + isObject(member.key) && + getStringName(member.key) === "render" && + (isObject(member.value) || isObject(member.body)) + ); + }); + + if (!isObject(renderMethod)) { + return null; + } + + if (isAstNode(renderMethod.value)) { + return renderMethod.value; + } + + return isAstNode(renderMethod) ? renderMethod : null; +} + +function processJSX( + code: string, + node: JSXRootNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + sourceFileName: string, + insertionsByOffset: Map }> +): void { + if (isJSXElement(node)) { + addPendingAttributes( + code, + node.openingElement, + componentName, + ignoredComponents, + fragmentContext, + sourceFileName, + insertionsByOffset + ); + } + + for (const child of node.children ?? []) { + if (isJSXRoot(child)) { + processJSX( + code, + child, + "", + ignoredComponents, + fragmentContext, + sourceFileName, + insertionsByOffset + ); + } + } +} diff --git a/packages/bundler-plugins/src/core/component-annotation-vite.ts b/packages/bundler-plugins/src/core/component-annotation-vite.ts new file mode 100644 index 00000000..20fd2df1 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite.ts @@ -0,0 +1,164 @@ +import path from "node:path"; + +import MagicString from "magic-string"; + +import { KNOWN_INCOMPATIBLE_PLUGINS } from "../babel-plugin/constants"; +import { stripQueryAndHashFromPath } from "./utils"; +import { isAstNode } from "./component-annotation-vite-ast"; +import { collectViteComponentAnnotationInsertions } from "./component-annotation-vite-walk"; +import type { + AttributeInsertion, + ComponentAnnotationTransformMeta, + ComponentAnnotationTransformResult, + MagicStringLike, + ParseAstAsync, +} from "./component-annotation-vite-ast"; + +export type { + ComponentAnnotationTransformMeta, + ComponentAnnotationTransformResult, +} from "./component-annotation-vite-ast"; + +// Keep this as a superset of JSX tag starts Babel can annotate, because a miss suppresses Babel fallback. +const JSX_TAG_START_REGEXP = /<[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}.:-]*|<>/u; +const JSX_FILE_REGEXP = /\.[jt]sx$/; + +function isViteAnnotationFile(idWithoutQueryAndHash: string): boolean { + if (idWithoutQueryAndHash.match(/\\node_modules\\|\/node_modules\//)) { + return false; + } + + return JSX_FILE_REGEXP.test(idWithoutQueryAndHash); +} + +function shouldTryParse(code: string): boolean { + return JSX_TAG_START_REGEXP.test(code); +} + +function shouldSkipIncompatibleFile(idWithoutQueryAndHash: string): boolean { + return KNOWN_INCOMPATIBLE_PLUGINS.some((pluginName) => { + return ( + idWithoutQueryAndHash.includes(`/node_modules/${pluginName}/`) || + idWithoutQueryAndHash.includes(`\\node_modules\\${pluginName}\\`) + ); + }); +} + +function escapeAttributeValue(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """); +} + +function makeAttributeText( + code: string, + insertionOffset: number, + attributes: AttributeInsertion["attributes"] +): string { + const previousCharIsWhitespace = + insertionOffset > 0 && /\s/.test(code[insertionOffset - 1] ?? ""); + const prefix = previousCharIsWhitespace ? "" : " "; + const suffix = previousCharIsWhitespace && code[insertionOffset] === "/" ? " " : ""; + const attributeText = attributes + .map(([name, value]) => `${name}="${escapeAttributeValue(value)}"`) + .join(" "); + + return `${prefix}${attributeText}${suffix}`; +} + +function getMagicString( + code: string, + meta?: ComponentAnnotationTransformMeta +): { magicString: MagicStringLike; isNative: boolean } { + if (meta?.magicString) { + return { magicString: meta.magicString, isNative: true }; + } + + return { magicString: new MagicString(code), isNative: false }; +} + +async function annotateWithViteParser( + code: string, + id: string, + ignoredComponents: string[], + parseAstAsync: ParseAstAsync, + meta?: ComponentAnnotationTransformMeta +): Promise { + const idWithoutQueryAndHash = stripQueryAndHashFromPath(id); + + if ( + !idWithoutQueryAndHash || + !isViteAnnotationFile(idWithoutQueryAndHash) || + !shouldTryParse(code) || + shouldSkipIncompatibleFile(idWithoutQueryAndHash) + ) { + return null; + } + + const ast = await parseAstAsync(code, { + lang: idWithoutQueryAndHash.endsWith(".jsx") ? "jsx" : "tsx", + }); + + if (!isAstNode(ast)) { + return null; + } + + const insertions = collectViteComponentAnnotationInsertions( + code, + ast, + ignoredComponents, + path.basename(idWithoutQueryAndHash) + ); + + if (insertions.length === 0) { + return null; + } + + const { magicString, isNative } = getMagicString(code, meta); + + for (const insertion of insertions) { + magicString.appendLeft( + insertion.offset, + makeAttributeText(code, insertion.offset, insertion.attributes) + ); + } + + if (isNative) { + return { code: magicString as unknown as string }; + } + + return { + code: magicString.toString(), + map: magicString.generateMap?.({ + file: id, + source: idWithoutQueryAndHash, + includeContent: true, + hires: true, + }), + }; +} + +export function createViteComponentNameAnnotateHooks( + ignoredComponents: string[], + getParseAstAsync: () => Promise +): { + transform( + code: string, + id: string, + meta?: ComponentAnnotationTransformMeta + ): Promise; +} { + return { + async transform(code, id, meta) { + try { + const parseAstAsync = await getParseAstAsync(); + + if (!parseAstAsync) { + return undefined; + } + + return await annotateWithViteParser(code, id, ignoredComponents, parseAstAsync, meta); + } catch { + return undefined; + } + }, + }; +} diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index 425c86f9..d59db7dd 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -1,11 +1,40 @@ -import { transformAsync } from "@babel/core"; -import componentNameAnnotatePlugin, { - experimentalComponentNameAnnotatePlugin, -} from "../babel-plugin"; import SentryCli from "@sentry/cli"; import { debug } from "@sentry/core"; import * as fs from "fs"; import { CodeInjection, containsOnlyImports, stripQueryAndHashFromPath } from "./utils"; +import type { transformAsync as babelTransformAsync } from "@babel/core"; +import type componentNameAnnotatePlugin from "../babel-plugin"; +import type { experimentalComponentNameAnnotatePlugin } from "../babel-plugin"; + +type BabelTransformAsync = typeof babelTransformAsync; +type BabelParserPlugins = NonNullable< + NonNullable[1]>["parserOpts"] +>["plugins"]; +type BabelAnnotationRuntime = { + transformAsync: BabelTransformAsync; + componentNameAnnotatePlugin: typeof componentNameAnnotatePlugin; + experimentalComponentNameAnnotatePlugin: typeof experimentalComponentNameAnnotatePlugin; +}; + +let babelAnnotationRuntimePromise: Promise | undefined; + +function loadBabelAnnotationRuntime(): Promise { + if (!babelAnnotationRuntimePromise) { + babelAnnotationRuntimePromise = Promise.all([ + import("@babel/core"), + import("../babel-plugin"), + ]).then(([babel, babelPlugin]) => { + return { + transformAsync: babel.transformAsync, + componentNameAnnotatePlugin: babelPlugin.default, + experimentalComponentNameAnnotatePlugin: + babelPlugin.experimentalComponentNameAnnotatePlugin, + }; + }); + } + + return babelAnnotationRuntimePromise; +} /** * Determines whether the Sentry CLI binary is in its expected location. @@ -69,10 +98,6 @@ export function createComponentNameAnnotateHooks( ignoredComponents: string[], injectIntoHtml: boolean ) { - type ParserPlugins = NonNullable< - NonNullable[1]>["parserOpts"] - >["plugins"]; - return { async transform(this: void, code: string, id: string) { // id may contain query and hash which will trip up our file extension logic below @@ -87,13 +112,18 @@ export function createComponentNameAnnotateHooks( return null; } - const parserPlugins: ParserPlugins = []; + const parserPlugins: BabelParserPlugins = []; if (idWithoutQueryAndHash.endsWith(".jsx")) { parserPlugins.push("jsx"); } else if (idWithoutQueryAndHash.endsWith(".tsx")) { parserPlugins.push("jsx", "typescript"); } + const { + transformAsync, + componentNameAnnotatePlugin, + experimentalComponentNameAnnotatePlugin, + } = await loadBabelAnnotationRuntime(); const plugin = injectIntoHtml ? experimentalComponentNameAnnotatePlugin : componentNameAnnotatePlugin; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 926e4c47..d2043b2a 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -14,12 +14,33 @@ import { replaceBooleanFlagsInCode, CodeInjection, } from "../core"; +import type { + ComponentAnnotationTransformMeta, + ComponentAnnotationTransformResult, +} from "../core/component-annotation-vite"; import type { SourceMap } from "magic-string"; import MagicString from "magic-string"; import type { TransformResult } from "rollup"; import * as path from "node:path"; import { createRequire } from "node:module"; +type ViteModule = { + parseAstAsync?: (code: string, options: { lang: "jsx" | "tsx" }) => Promise; +}; + +type ViteParseAstAsync = NonNullable; +type ViteAnnotationHooks = { + transform( + code: string, + id: string, + meta?: ComponentAnnotationTransformMeta + ): Promise; +}; + +let viteParseAstAsyncPromise: Promise | undefined; + +const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; + function hasExistingDebugID(code: string): boolean { // Check if a debug ID has already been injected to avoid duplicate injection (e.g. by another plugin or Sentry CLI) const chunkStartSnippet = code.slice(0, 6000); @@ -49,6 +70,32 @@ function getRollupMajorVersion(): string | undefined { return undefined; } +function getViteParseAstAsync(): Promise { + if (!viteParseAstAsyncPromise) { + viteParseAstAsyncPromise = Promise.resolve() + .then(async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Vite is an optional runtime peer for this package + const viteModule = createRequire(import.meta.url)("vite") as ViteModule; + + if (typeof viteModule.parseAstAsync !== "function") { + return null; + } + + try { + await viteModule.parseAstAsync("const x =
;", { lang: "tsx" }); + } catch { + return null; + } + + return viteModule.parseAstAsync; + }) + .catch(() => null); + } + + return viteParseAstAsyncPromise; +} + /** * @ignore - this is the internal plugin factory function only used for the Vite plugin! */ @@ -116,6 +163,31 @@ export function _rollupPluginInternal( !!options.reactComponentAnnotation?._experimentalInjectIntoHtml ) : undefined; + const transformViteAnnotations = + options.reactComponentAnnotation?.enabled && + buildTool === "vite" && + buildToolMajorVersion === "8" && + !options.reactComponentAnnotation?._experimentalInjectIntoHtml + ? (() => { + let viteAnnotationHooksPromise: Promise | undefined; + + return { + transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { + if (!viteAnnotationHooksPromise) { + viteAnnotationHooksPromise = import("../core/component-annotation-vite").then( + ({ createViteComponentNameAnnotateHooks }) => + createViteComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + getViteParseAstAsync + ) + ); + } + + return viteAnnotationHooksPromise.then((hooks) => hooks.transform(code, id, meta)); + }, + }; + })() + : undefined; const transformReplace = Object.keys(replacementValues).length > 0; const shouldTransform = transformAnnotations || transformReplace; @@ -126,11 +198,27 @@ export function _rollupPluginInternal( }); } - async function transform(code: string, id: string): Promise { + async function transform( + code: string, + id: string, + meta?: ComponentAnnotationTransformMeta + ): Promise { // Component annotations are only in user code and boolean flag replacements are // only in Sentry code. If we successfully add annotations, we can return early. + let shouldRunBabelAnnotations = true; + + if (transformViteAnnotations?.transform) { + const result = await transformViteAnnotations.transform(code, id, meta); + if (result) { + return result; + } + + if (result === null) { + shouldRunBabelAnnotations = false; + } + } - if (transformAnnotations?.transform) { + if (shouldRunBabelAnnotations && transformAnnotations?.transform) { const result = await transformAnnotations.transform(code, id); if (result) { return result; @@ -237,10 +325,18 @@ export function _rollupPluginInternal( const name = `sentry-${buildTool}-plugin`; if (shouldTransform) { + const transformHook = + buildTool === "vite" + ? { + filter: { id: JS_MODULE_ID_FILTER }, + handler: transform, + } + : transform; + return { name, buildStart, - transform, + transform: transformHook, renderChunk, writeBundle, }; diff --git a/packages/bundler-plugins/test/core/component-annotation-vite.test.ts b/packages/bundler-plugins/test/core/component-annotation-vite.test.ts new file mode 100644 index 00000000..d651789e --- /dev/null +++ b/packages/bundler-plugins/test/core/component-annotation-vite.test.ts @@ -0,0 +1,327 @@ +import { transformAsync, traverse, types as t } from "@babel/core"; +import { parse } from "@babel/parser"; +import MagicString from "magic-string"; +import { describe, expect, it, vi } from "vitest"; + +import componentNameAnnotatePlugin from "../../src/babel-plugin"; +import { + createViteComponentNameAnnotateHooks, + type ComponentAnnotationTransformResult, +} from "../../src/core/component-annotation-vite"; + +type Annotation = { + elementName: string; + attributes: Record; +}; + +const SENTRY_ATTRIBUTES = new Set([ + "data-sentry-component", + "data-sentry-element", + "data-sentry-source-file", +]); + +async function parseAstAsync(code: string, options: { lang: "jsx" | "tsx" }): Promise { + return parse(code, { + sourceType: "module", + plugins: options.lang === "tsx" ? ["jsx", "typescript"] : ["jsx"], + }); +} + +function collectAnnotations(code: string, id: string): Annotation[] { + const ast = parse(code, { + sourceType: "module", + plugins: id.endsWith(".tsx") ? ["jsx", "typescript"] : ["jsx"], + }); + const annotations: Annotation[] = []; + + traverse(ast, { + JSXOpeningElement(path) { + const attributes: Record = {}; + + for (const attribute of path.node.attributes) { + if ( + !t.isJSXAttribute(attribute) || + !t.isJSXIdentifier(attribute.name) || + !SENTRY_ATTRIBUTES.has(attribute.name.name) || + !t.isStringLiteral(attribute.value) + ) { + continue; + } + + attributes[attribute.name.name] = attribute.value.value; + } + + if (Object.keys(attributes).length > 0) { + annotations.push({ + elementName: path.get("name").toString(), + attributes, + }); + } + }, + }); + + return annotations; +} + +async function annotateWithBabel( + code: string, + id: string, + ignoredComponents: string[] +): Promise { + const result = await transformAsync(code, { + filename: id, + configFile: false, + babelrc: false, + plugins: [[componentNameAnnotatePlugin, { ignoredComponents }]], + parserOpts: { + sourceType: "module", + allowAwaitOutsideFunction: true, + plugins: id.endsWith(".tsx") ? ["jsx", "typescript"] : ["jsx"], + }, + generatorOpts: { + decoratorsBeforeExport: true, + }, + }); + + expect(result?.code).toBeDefined(); + + return collectAnnotations(result?.code ?? "", id); +} + +async function annotateWithVite( + code: string, + id: string, + ignoredComponents: string[] = [] +): Promise { + const hooks = createViteComponentNameAnnotateHooks(ignoredComponents, async () => parseAstAsync); + + return hooks.transform(code, id); +} + +describe("createViteComponentNameAnnotateHooks", () => { + it.each([ + [ + "function declarations and nested children", + "/src/app.jsx", + `import React from "react"; + +export default function App() { + return ( +
+ + ignored dom element +
+ ); +}`, + [], + ], + [ + "arrow function expression bodies", + "/src/arrow.jsx", + `import React from "react"; + +const ArrowComponent = () => ( + + + +); + +export default ArrowComponent;`, + [], + ], + [ + "class render methods", + "/src/class-component.jsx", + `import React, { Component } from "react"; + +export class ClassComponent extends Component { + render() { + return ( + + + + ); + } +}`, + [], + ], + [ + "class render methods with nested render helpers", + "/src/class-nested-helper.jsx", + `import React, { Component } from "react"; + +export class ClassComponent extends Component { + render() { + const Helper = () => { + return ; + }; + + return {Helper()}; + } +}`, + [], + ], + [ + "anonymous default class render methods", + "/src/anonymous-class.jsx", + `import React from "react"; + +export default class extends React.Component { + render() { + return ; + } +}`, + [], + ], + [ + "anonymous class render methods with nested render helpers", + "/src/anonymous-class-nested-helper.jsx", + `import React from "react"; + +export default class extends React.Component { + render() { + const Helper = () => { + return ; + }; + + return {Helper()}; + } +}`, + [], + ], + [ + "conditional returns", + "/src/conditional.jsx", + `import React from "react"; + +const maybeTrue = Math.random() > 0.5; + +export default function ConditionalComponent() { + return maybeTrue ? : ; +}`, + [], + ], + [ + "fragment aliases", + "/src/fragments.jsx", + `import React, { Fragment as ImportedFragment } from "react"; +import * as ReactNamespace from "react"; + +const { Fragment: DestructuredFragment } = React; +const AssignedFragment = ImportedFragment; + +export default function FragmentComponent() { + return ( +
+ + import alias + + + namespace alias + + + destructured alias + + + assigned alias + +
+ ); +}`, + [], + ], + [ + "ignored component names and member expressions", + "/src/ignored.jsx", + `import React from "react"; +import { Tab } from "@headlessui/react"; +import { Components } from "my-ui-library"; + +export default function IgnoredComponent() { + return ( +
+ + + + + +
+ ); +}`, + ["Tab.Group", "Tab.List", "Components.UI.Button"], + ], + [ + "tsx files", + "/src/typed.tsx", + `import React from "react"; + +type Props = { title: string }; + +export function TypedComponent(props: Props) { + return ; +}`, + [], + ], + ])("matches Babel annotations for %s", async (_name, id, code, ignoredComponents) => { + const viteResult = await annotateWithVite(code, id, ignoredComponents); + + expect(viteResult).toBeTruthy(); + expect(collectAnnotations(viteResult?.code.toString() ?? "", id)).toEqual( + await annotateWithBabel(code, id, ignoredComponents) + ); + }); + + it.each(["_Foo", "$Foo", "Ωmega"])( + "parses JSX identifiers that start with %s", + async (elementName) => { + const code = `export const App = () => <${elementName} />;`; + const id = "/src/app.jsx"; + + const viteResult = await annotateWithVite(code, id); + + expect(viteResult).toBeTruthy(); + expect(collectAnnotations(viteResult?.code.toString() ?? "", id)).toEqual([ + { + elementName, + attributes: { + "data-sentry-component": "App", + "data-sentry-element": elementName, + "data-sentry-source-file": "app.jsx", + }, + }, + ]); + } + ); + + it("uses the native magicString object from transform metadata when it is available", async () => { + const code = `export function App() { + return <Custom />; +}`; + const id = "/src/app.jsx"; + const magicString = new MagicString(code); + const hooks = createViteComponentNameAnnotateHooks([], async () => parseAstAsync); + + const result = await hooks.transform(code, id, { magicString }); + + expect(result?.code).toBe(magicString as unknown as string); + expect(result?.code.toString()).toContain(`data-sentry-component="App"`); + }); + + it("returns null without parsing when the file cannot contain public Vite annotations", async () => { + const parse = vi.fn(parseAstAsync); + const hooks = createViteComponentNameAnnotateHooks([], async () => parse); + + await expect(hooks.transform("const value = 1;", "/src/app.js")).resolves.toBeNull(); + expect(parse).not.toHaveBeenCalled(); + }); + + it("returns undefined when parsing fails so callers can fall back to Babel", async () => { + const hooks = createViteComponentNameAnnotateHooks([], async () => { + throw new Error("parser unavailable"); + }); + + await expect( + hooks.transform("export const App = () => <Custom />;", "/src/app.jsx") + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/public-api.test.ts b/packages/bundler-plugins/test/rollup/public-api.test.ts index 3b4113c0..e9b1c323 100644 --- a/packages/bundler-plugins/test/rollup/public-api.test.ts +++ b/packages/bundler-plugins/test/rollup/public-api.test.ts @@ -1,12 +1,110 @@ -import { sentryRollupPlugin } from "../../src/rollup"; +import { _rollupPluginInternal, sentryRollupPlugin } from "../../src/rollup"; +import { createComponentNameAnnotateHooks } from "../../src/core"; import type { Plugin, SourceMap } from "rollup"; import { describe, it, expect, test, beforeEach, vi } from "vitest"; +const { + babelCoreImportMock, + transformAsyncMock, + viteAnnotationModuleImportMock, + viteAnnotationTransformMock, +} = vi.hoisted(() => { + return { + babelCoreImportMock: vi.fn(), + transformAsyncMock: vi.fn(async (code: string) => ({ code, map: null })), + viteAnnotationModuleImportMock: vi.fn(), + viteAnnotationTransformMock: vi.fn(async () => ({ code: "fast-path", map: null })), + }; +}); + +vi.mock("@babel/core", () => { + babelCoreImportMock(); + return { + transformAsync: transformAsyncMock, + }; +}); + +vi.mock("../../src/core/component-annotation-vite", () => { + viteAnnotationModuleImportMock(); + return { + createViteComponentNameAnnotateHooks: vi.fn(() => ({ + transform: viteAnnotationTransformMock, + })), + }; +}); + +async function runTransform(plugin: Plugin, code: string, id: string): Promise<unknown> { + const transform = plugin.transform; + + if (typeof transform === "function") { + return await transform.call({} as never, code, id); + } + + return await transform?.handler.call({} as never, code, id); +} + test("Rollup plugin should exist", () => { expect(sentryRollupPlugin).toBeDefined(); expect(typeof sentryRollupPlugin).toBe("function"); }); +test("component annotations only load Babel when the Babel transform runs", async () => { + expect(babelCoreImportMock).not.toHaveBeenCalled(); + + const hooks = createComponentNameAnnotateHooks([], false); + + await hooks.transform("const x = 1;", "/src/plain.js"); + + expect(babelCoreImportMock).not.toHaveBeenCalled(); + + await hooks.transform("export function App() { return <div />; }", "/src/app.jsx"); + + expect(babelCoreImportMock).toHaveBeenCalledTimes(1); + expect(transformAsyncMock).toHaveBeenCalledTimes(1); +}); + +test("Vite annotation fast path only loads for Vite 8 annotation transforms", async () => { + expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); + + const vite7Plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + "vite", + "7" + ) as Plugin; + + await runTransform(vite7Plugin, "export function App() { return <div />; }", "/src/app.jsx"); + + expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); + + const vite8Plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + "vite", + "8" + ) as Plugin; + + await runTransform(vite8Plugin, "export function App() { return <div />; }", "/src/app.jsx"); + + expect(viteAnnotationModuleImportMock).toHaveBeenCalledTimes(1); + expect(viteAnnotationTransformMock).toHaveBeenCalledTimes(1); +}); + +test("uses a Rollup 3-compatible function transform hook for Rollup builds", () => { + const [rollupPlugin] = sentryRollupPlugin({ + release: { inject: false }, + reactComponentAnnotation: { enabled: true }, + }) as [Plugin]; + + expect(typeof rollupPlugin.transform).toBe("function"); + + const vitePlugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + "vite", + "8" + ) as Plugin; + + expect(typeof vitePlugin.transform).toBe("object"); +}); + describe("sentryRollupPlugin", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/integration-tests-next/fixtures/vite8/component-annotation.test.ts b/packages/integration-tests-next/fixtures/vite8/component-annotation.test.ts index b6991cfc..388a4f11 100644 --- a/packages/integration-tests-next/fixtures/vite8/component-annotation.test.ts +++ b/packages/integration-tests-next/fixtures/vite8/component-annotation.test.ts @@ -39,13 +39,13 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { "data-sentry-source-file": "app.jsx" }, void 0, false, { fileName: _jsxFileName, - lineNumber: 4, + lineNumber: 6, columnNumber: 7 }, this), ";"] }, void 0, true, { fileName: _jsxFileName, - lineNumber: 3, - columnNumber: 10 + lineNumber: 5, + columnNumber: 5 }, this); } console.log(App());