From e04ab6a375be2d7b97af35fde91ac91a0fed10ff Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 8 Jul 2026 14:44:28 +0200 Subject: [PATCH 1/2] perf(vite): add fast component annotation transform --- .../vite8/component-annotation.test.ts | 6 +- .../src/babel-plugin/component-annotation.ts | 57 ++++ .../bundler-plugins/src/babel-plugin/index.ts | 107 ++---- .../src/core/component-annotation-vite-ast.ts | 91 +++++ .../component-annotation-vite-fragments.ts | 134 ++++++++ .../src/core/component-annotation-vite-jsx.ts | 196 +++++++++++ .../core/component-annotation-vite-walk.ts | 172 ++++++++++ .../src/core/component-annotation-vite.ts | 158 +++++++++ packages/bundler-plugins/src/core/index.ts | 37 ++- packages/bundler-plugins/src/rollup/index.ts | 102 +++++- .../core/component-annotation-vite.test.ts | 314 ++++++++++++++++++ .../test/rollup/public-api.test.ts | 96 +++++- 12 files changed, 1383 insertions(+), 87 deletions(-) create mode 100644 packages/bundler-plugins/src/babel-plugin/component-annotation.ts create mode 100644 packages/bundler-plugins/src/core/component-annotation-vite-ast.ts create mode 100644 packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts create mode 100644 packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts create mode 100644 packages/bundler-plugins/src/core/component-annotation-vite-walk.ts create mode 100644 packages/bundler-plugins/src/core/component-annotation-vite.ts create mode 100644 packages/bundler-plugins/test/core/component-annotation-vite.test.ts diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation.test.ts index b6991cfcd6f0..388a4f11f7ae 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/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()); 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 000000000000..534ff4274730 --- /dev/null +++ b/packages/bundler-plugins/src/babel-plugin/component-annotation.ts @@ -0,0 +1,57 @@ +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 fa3884f8dcaa..ac97b5ff2be8 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 */ @@ -327,65 +329,25 @@ 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))); } } @@ -434,12 +396,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 { @@ -592,21 +554,16 @@ 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 000000000000..ce8baf3e33ec --- /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 000000000000..82996b468f9b --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts @@ -0,0 +1,134 @@ +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 000000000000..35e25b5f8cba --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts @@ -0,0 +1,196 @@ +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 000000000000..d652e4c86656 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts @@ -0,0 +1,172 @@ +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 }>(); + + 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 000000000000..66d2d4a8d3e9 --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotation-vite.ts @@ -0,0 +1,158 @@ +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 bd58dbcd557e..a72054a86115 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -1,9 +1,36 @@ -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[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. @@ -61,8 +88,6 @@ export { globFiles } from './glob'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createComponentNameAnnotateHooks(ignoredComponents: string[], injectIntoHtml: boolean) { - type ParserPlugins = 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 @@ -77,13 +102,15 @@ export function createComponentNameAnnotateHooks(ignoredComponents: string[], in 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; try { diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index b275f9a3c25f..c8371fe20a27 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); @@ -46,6 +67,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! */ @@ -109,6 +156,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; @@ -119,11 +191,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 (transformAnnotations?.transform) { + if (transformViteAnnotations?.transform) { + const result = await transformViteAnnotations.transform(code, id, meta); + if (result) { + return result; + } + + if (result === null) { + shouldRunBabelAnnotations = false; + } + } + + if (shouldRunBabelAnnotations && transformAnnotations?.transform) { const result = await transformAnnotations.transform(code, id); if (result) { return result; @@ -228,10 +316,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 000000000000..789437dd7903 --- /dev/null +++ b/packages/bundler-plugins/test/core/component-annotation-vite.test.ts @@ -0,0 +1,314 @@ +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 a6667d9a6492..b54077fce2bc 100644 --- a/packages/bundler-plugins/test/rollup/public-api.test.ts +++ b/packages/bundler-plugins/test/rollup/public-api.test.ts @@ -1,12 +1,106 @@ -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(); From 5954f4f81a208c6554e737ce9651a63dbcc77023 Mon Sep 17 00:00:00 2001 From: Tim Fish <tim@timfish.uk> Date: Thu, 9 Jul 2026 11:38:50 +0100 Subject: [PATCH 2/2] PR review --- .../bundler-plugins/src/core/component-annotation-vite.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bundler-plugins/src/core/component-annotation-vite.ts b/packages/bundler-plugins/src/core/component-annotation-vite.ts index 66d2d4a8d3e9..a0ce0907268e 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite.ts +++ b/packages/bundler-plugins/src/core/component-annotation-vite.ts @@ -94,8 +94,10 @@ async function annotateWithViteParser( lang: idWithoutQueryAndHash.endsWith('.jsx') ? 'jsx' : 'tsx', }); + // Fall through to the Babel fallback (return undefined, not null) when the parser yields an + // unexpected result, mirroring the catch path — null would suppress Babel and drop annotations. if (!isAstNode(ast)) { - return null; + return undefined; } const insertions = collectViteComponentAnnotationInsertions(