diff --git a/packages/babel-plugin-component-annotate/src/experimental.ts b/packages/babel-plugin-component-annotate/src/experimental.ts new file mode 100644 index 00000000..c970078d --- /dev/null +++ b/packages/babel-plugin-component-annotate/src/experimental.ts @@ -0,0 +1,607 @@ +/** + * MIT License + * + * Copyright (c) 2020 Engineering at FullStory + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +/** + * The following code is based on the FullStory Babel plugin, but has been modified to work + * with Sentry products: + * + * - Added `sentry` to data properties, i.e `data-sentry-component` + * - Converted to TypeScript + * - Code cleanups + * - Highly modified to inject the data attributes into the root HTML elements of a component. + */ + +import type * as Babel from "@babel/core"; +import type { PluginObj, PluginPass } from "@babel/core"; + +const REACT_NATIVE_ELEMENTS: string[] = [ + "Image", + "Text", + "View", + "ScrollView", + "TextInput", + "TouchableOpacity", + "TouchableHighlight", + "TouchableWithoutFeedback", + "FlatList", + "SectionList", + "ActivityIndicator", + "Button", + "Switch", + "Modal", + "SafeAreaView", + "StatusBar", + "KeyboardAvoidingView", + "RefreshControl", + "Picker", + "Slider", +]; + +interface AnnotationOpts { + native?: boolean; + ignoredComponents?: string[]; +} + +interface FragmentContext { + fragmentAliases: Set; + reactNamespaceAliases: Set; +} + +interface AnnotationPluginPass extends PluginPass { + opts: AnnotationOpts; + sentryFragmentContext?: FragmentContext; +} + +type AnnotationPlugin = PluginObj; + +// Shared context object for all JSX processing functions +interface JSXProcessingContext { + /** Babel types object */ + t: typeof Babel.types; + /** Name of the React component */ + componentName: string; + /** AAttribute name for the component */ + attributeName: string; + /** Array of component names to ignore */ + ignoredComponents: string[]; + /** Fragment context for identifying React fragments */ + fragmentContext?: FragmentContext; +} + +// We must export the plugin as default, otherwise the Babel loader will not be able to resolve it when configured using its string identifier +export function experimentalComponentNameAnnotatePlugin({ + types: t, +}: typeof Babel): AnnotationPlugin { + return { + visitor: { + Program: { + enter(path, state) { + const fragmentContext = collectFragmentContext(path); + state.sentryFragmentContext = fragmentContext; + }, + }, + FunctionDeclaration(path, state) { + if (!path.node.id || !path.node.id.name) { + return; + } + + const context = createJSXProcessingContext(state, t, path.node.id.name); + functionBodyPushAttributes(context, path); + }, + ArrowFunctionExpression(path, state) { + // We're expecting a `VariableDeclarator` like `const MyComponent =` + const parent = path.parent; + + if ( + !parent || + !("id" in parent) || + !parent.id || + !("name" in parent.id) || + !parent.id.name + ) { + return; + } + + const context = createJSXProcessingContext(state, t, parent.id.name); + functionBodyPushAttributes(context, path); + }, + ClassDeclaration(path, state) { + const name = path.get("id"); + const properties = path.get("body").get("body"); + const render = properties.find((prop) => { + return prop.isClassMethod() && prop.get("key").isIdentifier({ name: "render" }); + }); + + if (!render || !render.traverse) { + return; + } + + const context = createJSXProcessingContext(state, t, name.node?.name || ""); + + render.traverse({ + ReturnStatement(returnStatement) { + const arg = returnStatement.get("argument"); + + if (!arg.isJSXElement() && !arg.isJSXFragment()) { + return; + } + + processJSX(context, arg); + }, + }); + }, + }, + }; +} + +/** + * Checks if an element name represents an HTML element (as opposed to a React component). + * HTML elements include standard lowercase HTML tags and React Native elements. + */ +function isHtmlElement(elementName: string): boolean { + // Unknown elements are not HTML elements + if (elementName === UNKNOWN_ELEMENT_NAME) { + return false; + } + + // Check for lowercase first letter (standard HTML elements) + if (elementName.length > 0 && elementName.charAt(0) === elementName.charAt(0).toLowerCase()) { + return true; + } + + // React Native elements typically start with uppercase but are still "native" elements + // We consider them HTML-like elements for annotation purposes + if (REACT_NATIVE_ELEMENTS.includes(elementName)) { + return true; + } + + // Otherwise, assume it's a React component (PascalCase) + return false; +} + +/** + * Creates a JSX processing context from the plugin state + */ +function createJSXProcessingContext( + state: AnnotationPluginPass, + t: typeof Babel.types, + componentName: string +): JSXProcessingContext { + return { + t, + componentName, + attributeName: attributeNamesFromState(state), + ignoredComponents: state.opts.ignoredComponents ?? [], + fragmentContext: state.sentryFragmentContext, + }; +} + +/** + * Processes the body of a function to add Sentry tracking attributes to JSX elements. + * Handles various function body structures including direct JSX returns, conditional expressions, + * and nested JSX elements. + */ +function functionBodyPushAttributes( + context: JSXProcessingContext, + path: Babel.NodePath +): void { + let jsxNode: Babel.NodePath; + + const functionBody = path.get("body").get("body"); + + if ( + !("length" in functionBody) && + functionBody.parent && + (functionBody.parent.type === "JSXElement" || functionBody.parent.type === "JSXFragment") + ) { + const maybeJsxNode = functionBody.find((c) => { + return c.type === "JSXElement" || c.type === "JSXFragment"; + }); + + if (!maybeJsxNode) { + return; + } + + jsxNode = maybeJsxNode; + } else { + const returnStatement = functionBody.find((c) => { + return c.type === "ReturnStatement"; + }); + if (!returnStatement) { + return; + } + + const arg = returnStatement.get("argument"); + if (!arg) { + return; + } + + if (Array.isArray(arg)) { + return; + } + + // Handle the case of a function body returning a ternary operation. + // `return (maybeTrue ? '' : ())` + if (arg.isConditionalExpression()) { + const consequent = arg.get("consequent"); + if (consequent.isJSXFragment() || consequent.isJSXElement()) { + processJSX(context, consequent); + } + const alternate = arg.get("alternate"); + if (alternate.isJSXFragment() || alternate.isJSXElement()) { + processJSX(context, alternate); + } + return; + } + + if (!arg.isJSXFragment() && !arg.isJSXElement()) { + return; + } + + jsxNode = arg; + } + + if (!jsxNode) { + return; + } + + processJSX(context, jsxNode); +} + +/** + * Recursively processes JSX elements to add Sentry tracking attributes. + * Handles both JSX elements and fragments, applying appropriate attributes + * based on configuration and component context. + */ +function processJSX(context: JSXProcessingContext, jsxNode: Babel.NodePath): void { + if (!jsxNode) { + return; + } + + // NOTE: I don't know of a case where `openingElement` would have more than one item, + // but it's safer to always iterate + const paths = jsxNode.get("openingElement"); + const openingElements = Array.isArray(paths) ? paths : [paths]; + + const hasInjectedAttributes = openingElements.reduce( + (prev, openingElement) => + prev || + applyAttributes( + context, + openingElement as Babel.NodePath, + context.componentName + ), + false + ); + + if (hasInjectedAttributes) { + return; + } + + let children = jsxNode.get("children"); + // TODO: See why `Array.isArray` doesn't have correct behaviour here + if (children && !("length" in children)) { + // A single child was found, maybe a bit of static text + children = [children]; + } + + children.forEach((child) => { + // Happens for some node types like plain text + if (!child.node) { + return; + } + + // If the current element is a fragment, children are still considered at root level + // Otherwise, children are not at root level + const openingElement = child.get("openingElement"); + // TODO: Improve this. We never expect to have multiple opening elements + // but if it's possible, this should work + if (Array.isArray(openingElement)) { + return; + } + + processJSX(context, child); + }); +} + +/** + * Applies Sentry tracking attributes to a JSX opening element. + * Adds component name, element name, and source file attributes while + * respecting ignore lists and fragment detection. + */ +function applyAttributes( + context: JSXProcessingContext, + openingElement: Babel.NodePath, + componentName: string +): boolean { + const { t, attributeName: componentAttributeName, ignoredComponents, fragmentContext } = context; + + // e.g., Raw JSX text like the `A` in `

a

` + if (!openingElement.node) { + return false; + } + + // Check if this is a React fragment - if so, skip attribute addition entirely + const isFragment = isReactFragment(t, openingElement, fragmentContext); + if (isFragment) { + return false; + } + + if (!openingElement.node.attributes) { + openingElement.node.attributes = []; + } + + const elementName = getPathName(t, openingElement); + + if (!isHtmlElement(elementName)) { + return false; + } + + const isAnIgnoredComponent = ignoredComponents.some( + (ignoredComponent) => ignoredComponent === componentName || ignoredComponent === elementName + ); + + // Add a stable attribute for the component name (only for root elements) + if (!isAnIgnoredComponent && !hasAttributeWithName(openingElement, componentAttributeName)) { + if (componentAttributeName) { + openingElement.node.attributes.push( + t.jSXAttribute(t.jSXIdentifier(componentAttributeName), t.stringLiteral(componentName)) + ); + } + } + + return true; +} + +function attributeNamesFromState(state: AnnotationPluginPass): string { + if (state.opts.native) { + return "dataSentryComponent"; + } + + return "data-sentry-component"; +} + +function collectFragmentContext(programPath: Babel.NodePath): FragmentContext { + const fragmentAliases = new Set(); + const reactNamespaceAliases = new Set(["React"]); // Default React namespace + + programPath.traverse({ + ImportDeclaration(importPath) { + const source = importPath.node.source.value; + + // Handle React imports + if (source === "react" || source === "React") { + importPath.node.specifiers.forEach((spec) => { + if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") { + // Detect aliased React.Fragment imports (e.g., `Fragment as F`) + // so we can later identify as a fragment in JSX. + if (spec.imported.name === "Fragment") { + fragmentAliases.add(spec.local.name); + } + } else if ( + spec.type === "ImportDefaultSpecifier" || + spec.type === "ImportNamespaceSpecifier" + ) { + // import React from 'react' -> React OR + // import * as React from 'react' -> React + reactNamespaceAliases.add(spec.local.name); + } + }); + } + }, + + // Handle simple variable assignments only (avoid complex cases) + VariableDeclarator(varPath) { + if (varPath.node.init) { + const init = varPath.node.init; + + // Handle identifier assignments: const MyFragment = Fragment + if (varPath.node.id.type === "Identifier") { + // Handle: const MyFragment = Fragment (only if Fragment is a known alias) + if (init.type === "Identifier" && fragmentAliases.has(init.name)) { + fragmentAliases.add(varPath.node.id.name); + } + + // Handle: const MyFragment = React.Fragment (only for known React namespaces) + if ( + init.type === "MemberExpression" && + init.object.type === "Identifier" && + init.property.type === "Identifier" && + init.property.name === "Fragment" && + reactNamespaceAliases.has(init.object.name) + ) { + fragmentAliases.add(varPath.node.id.name); + } + } + + // Handle destructuring assignments: const { Fragment } = React + if (varPath.node.id.type === "ObjectPattern") { + if (init.type === "Identifier" && reactNamespaceAliases.has(init.name)) { + const properties = varPath.node.id.properties; + + for (const prop of properties) { + if ( + prop.type === "ObjectProperty" && + prop.key && + prop.key.type === "Identifier" && + prop.value && + prop.value.type === "Identifier" && + prop.key.name === "Fragment" + ) { + fragmentAliases.add(prop.value.name); + } + } + } + } + } + }, + }); + + return { fragmentAliases, reactNamespaceAliases }; +} + +function isReactFragment( + t: typeof Babel.types, + openingElement: Babel.NodePath, + context?: FragmentContext // Add this optional parameter +): boolean { + // Handle JSX fragments (<>) + if (openingElement.isJSXFragment()) { + return true; + } + + const elementName = getPathName(t, openingElement); + + // Direct fragment references + if (elementName === "Fragment" || elementName === "React.Fragment") { + return true; + } + + // TODO: All these objects are typed as unknown, maybe an oversight in Babel types? + + // Check if the element name is a known fragment alias + if (context && elementName && context.fragmentAliases.has(elementName)) { + return true; + } + + // Handle JSXMemberExpression + if ( + openingElement.node && + "name" in openingElement.node && + openingElement.node.name && + typeof openingElement.node.name === "object" && + "type" in openingElement.node.name && + openingElement.node.name.type === "JSXMemberExpression" + ) { + const nodeName = openingElement.node.name; + if (typeof nodeName !== "object" || !nodeName) { + return false; + } + + if ("object" in nodeName && "property" in nodeName) { + const nodeNameObject = nodeName.object; + const nodeNameProperty = nodeName.property; + + if (typeof nodeNameObject !== "object" || typeof nodeNameProperty !== "object") { + return false; + } + + if (!nodeNameObject || !nodeNameProperty) { + return false; + } + + const objectName = "name" in nodeNameObject && nodeNameObject.name; + const propertyName = "name" in nodeNameProperty && nodeNameProperty.name; + + // React.Fragment check + if (objectName === "React" && propertyName === "Fragment") { + return true; + } + + // Enhanced checks using context + if (context) { + // Check React.Fragment pattern with known React namespaces + if ( + context.reactNamespaceAliases.has(objectName as string) && + propertyName === "Fragment" + ) { + return true; + } + + // Check MyFragment.Fragment pattern + if (context.fragmentAliases.has(objectName as string) && propertyName === "Fragment") { + return true; + } + } + } + } + + return false; +} + +function hasAttributeWithName( + openingElement: Babel.NodePath, + name: string | undefined | null +): boolean { + if (!name) { + return false; + } + + return openingElement.node.attributes.some((node) => { + if (node.type === "JSXAttribute") { + return node.name.name === name; + } + + return false; + }); +} + +function getPathName(t: typeof Babel.types, path: Babel.NodePath): string { + if (!path.node) return UNKNOWN_ELEMENT_NAME; + if (!("name" in path.node)) { + return UNKNOWN_ELEMENT_NAME; + } + + const name = path.node.name; + + if (typeof name === "string") { + return name; + } + + if (t.isIdentifier(name) || t.isJSXIdentifier(name)) { + return name.name; + } + + if (t.isJSXNamespacedName(name)) { + return name.name.name; + } + + // Handle JSX member expressions like Tab.Group + if (t.isJSXMemberExpression(name)) { + const objectName = getJSXMemberExpressionObjectName(t, name.object); + const propertyName = name.property.name; + return `${objectName}.${propertyName}`; + } + + return UNKNOWN_ELEMENT_NAME; +} + +// Recursively handle nested member expressions (e.g. Components.UI.Header) +function getJSXMemberExpressionObjectName( + t: typeof Babel.types, + object: Babel.types.JSXMemberExpression | Babel.types.JSXIdentifier +): string { + if (t.isJSXIdentifier(object)) { + return object.name; + } + if (t.isJSXMemberExpression(object)) { + const objectName = getJSXMemberExpressionObjectName(t, object.object); + return `${objectName}.${object.property.name}`; + } + + return UNKNOWN_ELEMENT_NAME; +} + +const UNKNOWN_ELEMENT_NAME = "unknown"; diff --git a/packages/babel-plugin-component-annotate/src/index.ts b/packages/babel-plugin-component-annotate/src/index.ts index 54163dfd..8cb7495f 100644 --- a/packages/babel-plugin-component-annotate/src/index.ts +++ b/packages/babel-plugin-component-annotate/src/index.ts @@ -81,6 +81,8 @@ interface JSXProcessingContext { fragmentContext?: FragmentContext; } +export { experimentalComponentNameAnnotatePlugin } from "./experimental"; + // We must export the plugin as default, otherwise the Babel loader will not be able to resolve it when configured using its string identifier export default function componentNameAnnotatePlugin({ types: t }: typeof Babel): AnnotationPlugin { return { diff --git a/packages/babel-plugin-component-annotate/test/experimental.test.ts b/packages/babel-plugin-component-annotate/test/experimental.test.ts new file mode 100644 index 00000000..2f2fa189 --- /dev/null +++ b/packages/babel-plugin-component-annotate/test/experimental.test.ts @@ -0,0 +1,2058 @@ +/** + * MIT License + * + * Copyright (c) 2020 Engineering at FullStory + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { transform } from "@babel/core"; +import { experimentalComponentNameAnnotatePlugin as plugin } from "../src/index"; + +const BananasPizzaAppStandardInput = `import React, { Component } from 'react'; +import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + +UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = "String"; + +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return ; + } +} + +class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { text: '' }; + } + + render() { + return + this.setState({ text })} value={this.state.text} /> + + {this.state.text.split(' ').map(word => word && '🍕').join(' ')} + + ; + } +} + +export default function App() { + return + FullStory ReactNative testing app + + + ; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } +});`; + +it("unknown-element snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return

A

; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(\\"bogus\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"A\\")); + } + } + export default componentName;" + `); +}); + +it("component-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"A\\"); + } + } + export default componentName;" + `); +}); + +it("component-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"A\\"); + } + } + export default componentName;" + `); +}); + +it("component-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return <>A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"A\\"); + } + } + export default componentName;" + `); +}); + +it("component-annotate-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return <> +

Hello world

+ ; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + } + } + export default componentName;" + `); +}); + +it("arrow-noreturn-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + export default componentName;" + `); +}); + +it("arrow-noreturn-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + <> +

Hello world

+ +); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + export default componentName;" + `); +}); + +it("arrow-noreturn-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + export default componentName;" + `); +}); + +it("arrow-noreturn-annotate-trivial-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + Hello world +); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, \\"Hello world\\"); + export default componentName;" + `); +}); + +it("arrow snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("option-attribute snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("component snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return
+

Hello world

+
; + } +} + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } + } + export default componentName;" + `); +}); + +it("rawfunction-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + function SubComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"Sub\\"); + } + const componentName = () => { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(SubComponent, null)); + }; + export default componentName;" + `); +}); + +it("rawfunction-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); + } + const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, null)); + }; + export default componentName;" + `); +}); + +it("rawfunction-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return <>Sub; +} + +const componentName = () => { + return <> + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); + } + const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, null)); + }; + export default componentName;" + `); +}); + +it("arrow-noreturn snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( +
+

Hello world

+
+); + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + export default componentName;" + `); +}); + +it("tags snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; +import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + +UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = "String"; + +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return ; + } +} + +class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { text: '' }; + } + + render() { + return + this.setState({ text })} value={this.state.text} /> + + {this.state.text.split(' ').map(word => word && '🍕').join(' ')} + + ; + } +} + +export default function App() { + return + FullStory ReactNative testing app + + + ; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } +}); +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryComponent: \\"Bananas\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryComponent: \\"PizzaTranslator\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + } + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryComponent: \\"App\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + } + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, null), /*#__PURE__*/React.createElement(PizzaTranslator, null)); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("option-format snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("pureComponent-fragment snapshot matches", () => { + const result = transform( + `import React, { Fragment } from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return +

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Fragment } from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"PureComponentName\\" + }, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); +}); + +it("pureComponent-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return <> +

Hello world

+ ; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"PureComponentName\\" + }, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); +}); + +it("pureComponent-react-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return +

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"PureComponentName\\" + }, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); +}); + +it("rawfunction snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return
Sub
; +} + +const componentName = () => { + return
+ +
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + function SubComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"SubComponent\\" + }, \\"Sub\\"); + } + const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(SubComponent, null)); + }; + export default componentName;" + `); +}); + +it("arrow-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => { + return +

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("arrow-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +const componentName = () => { + return <> +

Hello world

+ ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("arrow-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return +

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, \\"Hello world\\")); + }; + export default componentName;" + `); +}); + +it("nonJSX snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class TestClass extends Component { + test() { + return true; + } +} + +export default TestClass; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class TestClass extends Component { + test() { + return true; + } + } + export default TestClass;" + `); +}); + +it("arrow-anonymous-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => { + return (() => +

Hello world

+
)(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => { + return (() => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); + }; + export default componentName;" + `); +}); + +it("arrow-anonymous-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return (() => <> +

Hello world

+ )(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return (() => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); + }; + export default componentName;" + `); +}); + +it("arrow-anonymous-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return (() => +

Hello world

+
)(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => { + return (() => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); + }; + export default componentName;" + `); +}); + +it("pure snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return
+

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"PureComponentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); +}); + +it("component-fragment-native snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"A\\"); + } + } + export default componentName;" + `); +}); + +it("pure-native snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return
+

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + dataSentryComponent: \\"PureComponentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); +}); + +it("skips components marked in ignoredComponents", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoredComponents: ["Bananas"] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryComponent: \\"PizzaTranslator\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + } + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryComponent: \\"App\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + } + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, null), /*#__PURE__*/React.createElement(PizzaTranslator, null)); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("handles ternary operation returned by function body", () => { + const result = transform( + `const maybeTrue = Math.random() > 0.5; +export default function componentName() { + return (maybeTrue ? '' : ()) +}`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "const maybeTrue = Math.random() > 0.5; + export default function componentName() { + return maybeTrue ? '' : /*#__PURE__*/React.createElement(SubComponent, null); + }" + `); +}); + +it("ignores components with member expressions when in ignoredComponents", () => { + const result = transform( + `import React from 'react'; +import { Tab } from '@headlessui/react'; + +export default function TestComponent() { + return ( +
+ + + Tab 1 + Tab 2 + + + Content 1 + Content 2 + + +
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [plugin, { ignoredComponents: ["Tab.Group", "Tab.List", "Tab.Panels", "Tab.Panel"] }], + ], + } + ); + + // The component should be transformed but Tab.* components should not have annotations + expect(result?.code).toContain("React.createElement(Tab.Group"); + expect(result?.code).not.toContain('"data-sentry-element": "Tab.Group"'); + expect(result?.code).toContain("React.createElement(Tab.List"); + expect(result?.code).not.toContain('"data-sentry-element": "Tab.List"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + import { Tab } from '@headlessui/react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(Tab.Group, null, /*#__PURE__*/React.createElement(Tab.List, null, /*#__PURE__*/React.createElement(Tab, null, \\"Tab 1\\"), /*#__PURE__*/React.createElement(Tab, null, \\"Tab 2\\")), /*#__PURE__*/React.createElement(Tab.Panels, null, /*#__PURE__*/React.createElement(Tab.Panel, null, \\"Content 1\\"), /*#__PURE__*/React.createElement(Tab.Panel, null, \\"Content 2\\")))); + }" + `); +}); + +it("handles nested member expressions in component names", () => { + const result = transform( + `import React from 'react'; +import { Components } from 'my-ui-library'; + +export default function TestComponent() { + return ( +
+ Click me + Title +
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { ignoredComponents: ["Components.UI.Button"] }]], + } + ); + + // Components.UI.Button should be ignored but Components.UI.Card.Header should be annotated + expect(result?.code).toContain("React.createElement(Components.UI.Button"); + expect(result?.code).not.toContain('"data-sentry-element": "Components.UI.Button"'); + expect(result?.code).toContain("React.createElement(Components.UI.Card.Header"); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + import { Components } from 'my-ui-library'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(Components.UI.Button, null, \\"Click me\\"), /*#__PURE__*/React.createElement(Components.UI.Card.Header, null, \\"Title\\")); + }" + `); +}); + +it("Only injects in root html elements", () => { + const result = transform( + `import { Fragment as F } from 'react'; + +export default function TestComponent() { + return ( + +
+

Title

+

Content

+
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment as F } from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Title\\"), /*#__PURE__*/React.createElement(\\"p\\", null, \\"Content\\"))); + }" + `); +}); + +describe("Fragment Detection", () => { + it("ignores React.Fragment with member expression handling", () => { + const result = transform( + `import React from 'react'; + + export default function TestComponent() { + return ( + +
Content
+
+ ); + }`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(React.Fragment"); + expect(result?.code).not.toContain('"data-sentry-element": "React.Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content\\")); + }" + `); + }); + + it("ignores JSX fragments (<>)", () => { + const result = transform( + `export default function TestComponent() { + return ( + <> +
Content in JSX fragment
+ More content + + ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(React.Fragment"); + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "export default function TestComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in JSX fragment\\"), /*#__PURE__*/React.createElement(\\"span\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"More content\\")); + }" + `); + }); + + it("ignores Fragment imported with alias", () => { + const result = transform( + `import { Fragment as F } from 'react'; + +export default function TestComponent() { + return ( + +
Content in aliased fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(F"); + expect(result?.code).not.toContain('"data-sentry-element": "F"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment as F } from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in aliased fragment\\")); + }" + `); + }); + + it("ignores Fragment assigned to variable", () => { + const result = transform( + `import { Fragment } from 'react'; + +const MyFragment = Fragment; + +export default function TestComponent() { + return ( + +
Content in variable fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(MyFragment"); + expect(result?.code).not.toContain('"data-sentry-element": "MyFragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment } from 'react'; + const MyFragment = Fragment; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(MyFragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in variable fragment\\")); + }" + `); + }); + + it("ignores Fragment with React namespace alias", () => { + const result = transform( + `import * as MyReact from 'react'; + +export default function TestComponent() { + return ( + +
Content in namespaced fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(MyReact.Fragment"); + expect(result?.code).not.toContain('"data-sentry-element": "MyReact.Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import * as MyReact from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(MyReact.Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in namespaced fragment\\")); + }" + `); + }); + + it("ignores React default import with Fragment", () => { + const result = transform( + `import MyReact from 'react'; + +export default function TestComponent() { + return ( + +
Content in default import fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).toContain("React.createElement(MyReact.Fragment"); + expect(result?.code).not.toContain('"data-sentry-element": "MyReact.Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import MyReact from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(MyReact.Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in default import fragment\\")); + }" + `); + }); + + it("ignores multiple fragment patterns in same file", () => { + const result = transform( + `import React, { Fragment } from 'react'; + + const MyFragment = Fragment; + + export default function TestComponent() { + return ( +
+ <> +
JSX Fragment content
+ + + + Direct Fragment content + + + +

Variable Fragment content

+
+ + +

React.Fragment content

+
+
+ ); + }`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "MyFragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "React.Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Fragment } from 'react'; + const MyFragment = Fragment; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", null, \\"JSX Fragment content\\")), /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Direct Fragment content\\")), /*#__PURE__*/React.createElement(MyFragment, null, /*#__PURE__*/React.createElement(\\"p\\", null, \\"Variable Fragment content\\")), /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"React.Fragment content\\"))); + }" + `); + }); + + it("handles complex variable assignment chains", () => { + const result = transform( + `import { Fragment } from 'react'; + + const MyFragment = Fragment; + const AnotherFragment = MyFragment; + + export default function TestComponent() { + return ( + +
Content in chained fragment
+
+ ); + }`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "MyFragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "AnotherFragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment } from 'react'; + const MyFragment = Fragment; + const AnotherFragment = MyFragment; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(AnotherFragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in chained fragment\\")); + }" + `); + }); + + it("works with annotate-fragments option disabled", () => { + const result = transform( + `import { Fragment as F } from 'react'; + +export default function TestComponent() { + return ( + +
Content
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "F"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment as F } from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content\\")); + }" + `); + }); + + it("works with annotate-fragments option enabled", () => { + const result = transform( + `import { Fragment as F } from 'react'; + +export default function TestComponent() { + return ( + +
Content
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "F"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment as F } from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content\\")); + }" + `); + }); + + it("ignores Fragment from React destructuring", () => { + const result = transform( + `import React from 'react'; + +const { Fragment } = React; + +export default function TestComponent() { + return ( + +
Content in destructured fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + const { + Fragment + } = React; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in destructured fragment\\")); + }" + `); + }); + + it("ignores Fragment with destructuring alias", () => { + const result = transform( + `import React from 'react'; + +const { Fragment: MyFragment } = React; + +export default function TestComponent() { + return ( + +
Content in aliased destructured fragment
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "MyFragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + const { + Fragment: MyFragment + } = React; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(MyFragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content in aliased destructured fragment\\")); + }" + `); + }); + + it("ignores Fragment from mixed destructuring", () => { + const result = transform( + `import React from 'react'; + +const { Fragment, createElement, useState } = React; + +export default function TestComponent() { + return ( + +
Content with other destructured items
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + const { + Fragment, + createElement, + useState + } = React; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content with other destructured items\\")); + }" + `); + }); + + it("handles destructuring from aliased React imports", () => { + const result = transform( + `import MyReact from 'react'; + +const { Fragment } = MyReact; + +export default function TestComponent() { + return ( + +
Content from aliased React destructuring
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import MyReact from 'react'; + const { + Fragment + } = MyReact; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content from aliased React destructuring\\")); + }" + `); + }); + + it("handles destructuring from namespace imports", () => { + const result = transform( + `import * as ReactLib from 'react'; + +const { Fragment: F } = ReactLib; + +export default function TestComponent() { + return ( + +
Content from namespace destructuring
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "F"'); + expect(result?.code).toMatchInlineSnapshot(` + "import * as ReactLib from 'react'; + const { + Fragment: F + } = ReactLib; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"Content from namespace destructuring\\")); + }" + `); + }); + + it("handles multiple destructuring patterns in one file", () => { + const result = transform( + `import React from 'react'; +import * as MyReact from 'react'; + +const { Fragment } = React; +const { Fragment: AliasedFrag } = MyReact; + +export default function TestComponent() { + return ( +
+ + Regular destructured + + + +

Aliased destructured

+
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "AliasedFrag"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + import * as MyReact from 'react'; + const { + Fragment + } = React; + const { + Fragment: AliasedFrag + } = MyReact; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Regular destructured\\")), /*#__PURE__*/React.createElement(AliasedFrag, null, /*#__PURE__*/React.createElement(\\"p\\", null, \\"Aliased destructured\\"))); + }" + `); + }); + + it("combines all fragment patterns correctly", () => { + const result = transform( + `import React, { Fragment as ImportedF } from 'react'; + import * as MyReact from 'react'; + + const { Fragment: DestructuredF } = React; + const { Fragment } = MyReact; + const AssignedF = Fragment; // ← This uses the destructured Fragment from MyReact + + export default function TestComponent() { + return ( +
+ {/* JSX Fragment */} + <> + JSX Fragment content + + + {/* Imported alias */} + + Imported alias content + + + {/* Destructured */} + + Destructured content + + + {/* Destructured from namespace */} + + Namespace destructured content + + + {/* Variable assigned */} + + Variable assigned content + + + {/* React.Fragment */} + + React.Fragment content + + + {/* Namespace Fragment */} + + Namespace Fragment content + +
+ ); + }`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "ImportedF"'); + expect(result?.code).not.toContain('"data-sentry-element": "DestructuredF"'); + expect(result?.code).not.toContain('"data-sentry-element": "Fragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "AssignedF"'); + expect(result?.code).not.toContain('"data-sentry-element": "React.Fragment"'); + expect(result?.code).not.toContain('"data-sentry-element": "MyReact.Fragment"'); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Fragment as ImportedF } from 'react'; + import * as MyReact from 'react'; + const { + Fragment: DestructuredF + } = React; + const { + Fragment + } = MyReact; + const AssignedF = Fragment; // ← This uses the destructured Fragment from MyReact + + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + className: \\"container\\", + \\"data-sentry-component\\": \\"TestComponent\\" + }, /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"JSX Fragment content\\")), /*#__PURE__*/React.createElement(ImportedF, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Imported alias content\\")), /*#__PURE__*/React.createElement(DestructuredF, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Destructured content\\")), /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Namespace destructured content\\")), /*#__PURE__*/React.createElement(AssignedF, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Variable assigned content\\")), /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"React.Fragment content\\")), /*#__PURE__*/React.createElement(MyReact.Fragment, null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Namespace Fragment content\\"))); + }" + `); + }); + + it("handles Fragment aliased correctly when used by other non-Fragment components in a different scope", () => { + const result = transform( + `import { Fragment as OriginalF } from 'react'; +import { OtherComponent } from 'some-library'; + +function TestComponent() { + const F = OriginalF; + + // Use Fragment alias - should be ignored + return ( + +
This should NOT have data-sentry-element (Fragment)
+
+ ); +} + +function AnotherComponent() { + // Different component with same alias name in different function scope + const F = OtherComponent; + + return ( + +
This SHOULD have data-sentry-element (not Fragment)
+
+ ); +} +`, + { + filename: "/variable-assignment-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + + expect(result?.code).not.toContain('"data-sentry-element": "F"'); + expect(result?.code).toMatchInlineSnapshot(` + "import { Fragment as OriginalF } from 'react'; + import { OtherComponent } from 'some-library'; + function TestComponent() { + const F = OriginalF; + + // Use Fragment alias - should be ignored + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"TestComponent\\" + }, \\"This should NOT have data-sentry-element (Fragment)\\")); + } + function AnotherComponent() { + // Different component with same alias name in different function scope + const F = OtherComponent; + return /*#__PURE__*/React.createElement(F, null, /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"AnotherComponent\\" + }, \\"This SHOULD have data-sentry-element (not Fragment)\\")); + }" + `); + }); +}); diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index 555de622..a1afe46d 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -1,5 +1,7 @@ import { transformAsync } from "@babel/core"; -import componentNameAnnotatePlugin from "@sentry/babel-plugin-component-annotate"; +import componentNameAnnotatePlugin, { + experimentalComponentNameAnnotatePlugin, +} from "@sentry/babel-plugin-component-annotate"; import SentryCli from "@sentry/cli"; import { logger } from "@sentry/utils"; import * as fs from "fs"; @@ -33,7 +35,10 @@ type LegacyPlugins = { interface SentryUnpluginFactoryOptions { injectionPlugin: InjectionPlugin | LegacyPlugins; - componentNameAnnotatePlugin?: (ignoredComponents?: string[]) => UnpluginOptions; + componentNameAnnotatePlugin?: ( + ignoredComponents: string[], + injectIntoHtml: boolean + ) => UnpluginOptions; debugIdUploadPlugin: ( upload: (buildArtifacts: string[]) => Promise, logger: Logger, @@ -190,7 +195,10 @@ export function sentryUnpluginFactory({ } else { componentNameAnnotatePlugin && plugins.push( - componentNameAnnotatePlugin(options.reactComponentAnnotation.ignoredComponents) + componentNameAnnotatePlugin( + options.reactComponentAnnotation.ignoredComponents || [], + !!options.reactComponentAnnotation._experimentalInjectIntoHtml + ) ); } } @@ -391,7 +399,10 @@ export function createRollupDebugIdUploadHooks( }; } -export function createComponentNameAnnotateHooks(ignoredComponents?: string[]): { +export function createComponentNameAnnotateHooks( + ignoredComponents: string[], + injectIntoHtml: boolean +): { transform: UnpluginOptions["transform"]; } { type ParserPlugins = NonNullable< @@ -419,9 +430,13 @@ export function createComponentNameAnnotateHooks(ignoredComponents?: string[]): parserPlugins.push("jsx", "typescript"); } + const plugin = injectIntoHtml + ? experimentalComponentNameAnnotatePlugin + : componentNameAnnotatePlugin; + try { const result = await transformAsync(code, { - plugins: [[componentNameAnnotatePlugin, { ignoredComponents }]], + plugins: [[plugin, { ignoredComponents }]], filename: id, parserOpts: { sourceType: "module", diff --git a/packages/bundler-plugin-core/src/options-mapping.ts b/packages/bundler-plugin-core/src/options-mapping.ts index b017a9d8..d85321a0 100644 --- a/packages/bundler-plugin-core/src/options-mapping.ts +++ b/packages/bundler-plugin-core/src/options-mapping.ts @@ -70,6 +70,7 @@ export type NormalizedOptions = { | { enabled?: boolean; ignoredComponents?: string[]; + _experimentalInjectIntoHtml?: boolean; } | undefined; _metaOptions: { diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index f86aca5e..292b6bd5 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -354,6 +354,11 @@ export interface Options { * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. */ ignoredComponents?: string[]; + /** + * An experimental component annotation injection mode that injects + * annotations into HTML rather than React components. + */ + _experimentalInjectIntoHtml?: boolean; }; /** diff --git a/packages/dev-utils/src/generate-documentation-table.ts b/packages/dev-utils/src/generate-documentation-table.ts index 6e97771b..4c0bf776 100644 --- a/packages/dev-utils/src/generate-documentation-table.ts +++ b/packages/dev-utils/src/generate-documentation-table.ts @@ -421,6 +421,13 @@ type IncludeEntry = { "A list of strings representing the names of components to ignore. The plugin will not perform apply `data-sentry` annotations on the DOM element for these components.", supportedBundlers: ["webpack", "vite", "rollup"], }, + { + name: "_experimentalInjectIntoHtml", + type: "boolean", + fullDescription: + "An experimental component annotation injection mode that injects annotations into HTML rather than React components.", + supportedBundlers: ["webpack", "vite", "rollup"], + }, ], }, { diff --git a/packages/integration-tests/fixtures/component-name-annotate-experimental/component-name-annotate-experimental.test.ts b/packages/integration-tests/fixtures/component-name-annotate-experimental/component-name-annotate-experimental.test.ts new file mode 100644 index 00000000..5227d834 --- /dev/null +++ b/packages/integration-tests/fixtures/component-name-annotate-experimental/component-name-annotate-experimental.test.ts @@ -0,0 +1,37 @@ +import childProcess from "child_process"; +import path from "path"; +import { testIfNodeMajorVersionIsLessThan18 } from "../../utils/testIf"; + +// prettier-ignore +const SNAPSHOT = `"
Component A
"` +const ESBUILD_SNAPSHOT = `"
Component A
"`; + +function checkBundle(bundlePath: string, snapshot = SNAPSHOT): void { + const processOutput = childProcess.execSync(`node ${bundlePath}`, { encoding: "utf-8" }); + expect(processOutput.trim()).toMatchInlineSnapshot(snapshot); +} + +test("esbuild bundle", () => { + expect.assertions(1); + checkBundle(path.join(__dirname, "./out/esbuild/index.js"), ESBUILD_SNAPSHOT); +}); + +test("rollup bundle", () => { + expect.assertions(1); + checkBundle(path.join(__dirname, "./out/rollup/index.js")); +}); + +test("vite bundle", () => { + expect.assertions(1); + checkBundle(path.join(__dirname, "./out/vite/index.js")); +}); + +testIfNodeMajorVersionIsLessThan18("webpack 4 bundle if node is < 18", () => { + expect.assertions(1); + checkBundle(path.join(__dirname, "./out/webpack4/index.js")); +}); + +test("webpack 5 bundle", () => { + expect.assertions(1); + checkBundle(path.join(__dirname, "./out/webpack5/index.js")); +}); diff --git a/packages/integration-tests/fixtures/component-name-annotate-experimental/input/app.jsx b/packages/integration-tests/fixtures/component-name-annotate-experimental/input/app.jsx new file mode 100644 index 00000000..d4263e79 --- /dev/null +++ b/packages/integration-tests/fixtures/component-name-annotate-experimental/input/app.jsx @@ -0,0 +1,14 @@ +import { renderToString } from "react-dom/server"; +import { ComponentA } from "./component-a"; + +export default function App() { + return ; +} + +console.log( + renderToString( +
+ +
+ ) +); diff --git a/packages/integration-tests/fixtures/component-name-annotate-experimental/input/component-a.jsx b/packages/integration-tests/fixtures/component-name-annotate-experimental/input/component-a.jsx new file mode 100644 index 00000000..5d57ab22 --- /dev/null +++ b/packages/integration-tests/fixtures/component-name-annotate-experimental/input/component-a.jsx @@ -0,0 +1,3 @@ +export function ComponentA() { + return Component A; +} diff --git a/packages/integration-tests/fixtures/component-name-annotate-experimental/setup.ts b/packages/integration-tests/fixtures/component-name-annotate-experimental/setup.ts new file mode 100644 index 00000000..1894d1ba --- /dev/null +++ b/packages/integration-tests/fixtures/component-name-annotate-experimental/setup.ts @@ -0,0 +1,10 @@ +import * as path from "path"; +import { createCjsBundles } from "../../utils/create-cjs-bundles-for-react"; + +const entryPointPath = path.resolve(__dirname, "input", "app.jsx"); +const outputDir = path.resolve(__dirname, "out"); + +createCjsBundles({ index: entryPointPath }, outputDir, { + telemetry: false, + reactComponentAnnotation: { enabled: true, _experimentalInjectIntoHtml: true }, +}); diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts index 30ad55fd..db15f81d 100644 --- a/packages/rollup-plugin/src/index.ts +++ b/packages/rollup-plugin/src/index.ts @@ -10,10 +10,13 @@ import { } from "@sentry/bundler-plugin-core"; import type { UnpluginOptions } from "unplugin"; -function rollupComponentNameAnnotatePlugin(ignoredComponents?: string[]): UnpluginOptions { +function rollupComponentNameAnnotatePlugin( + ignoredComponents: string[], + injectIntoHtml: boolean +): UnpluginOptions { return { name: "sentry-rollup-component-name-annotate-plugin", - rollup: createComponentNameAnnotateHooks(ignoredComponents), + rollup: createComponentNameAnnotateHooks(ignoredComponents, injectIntoHtml), }; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index a6bc4ac7..303e4d91 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -20,11 +20,14 @@ function viteInjectionPlugin(injectionCode: string, debugIds: boolean): Unplugin }; } -function viteComponentNameAnnotatePlugin(ignoredComponents?: string[]): UnpluginOptions { +function viteComponentNameAnnotatePlugin( + ignoredComponents: string[], + injectIntoHtml: boolean +): UnpluginOptions { return { name: "sentry-vite-component-name-annotate-plugin", enforce: "pre" as const, - vite: createComponentNameAnnotateHooks(ignoredComponents), + vite: createComponentNameAnnotateHooks(ignoredComponents, injectIntoHtml), }; } diff --git a/packages/webpack-plugin/src/webpack4and5.ts b/packages/webpack-plugin/src/webpack4and5.ts index aaf8bf5f..09cfe4e4 100644 --- a/packages/webpack-plugin/src/webpack4and5.ts +++ b/packages/webpack-plugin/src/webpack4and5.ts @@ -69,15 +69,18 @@ function webpackInjectionPlugin( }); } -function webpackComponentNameAnnotatePlugin(): (ignoredComponents?: string[]) => UnpluginOptions { - return (ignoredComponents?: string[]) => ({ +function webpackComponentNameAnnotatePlugin(): ( + ignoredComponents: string[], + injectIntoHtml: boolean +) => UnpluginOptions { + return (ignoredComponents: string[], injectIntoHtml: boolean) => ({ name: "sentry-webpack-component-name-annotate-plugin", enforce: "pre", // Webpack needs this hook for loader logic, so the plugin is not run on unsupported file types transformInclude(id) { return id.endsWith(".tsx") || id.endsWith(".jsx"); }, - transform: createComponentNameAnnotateHooks(ignoredComponents).transform, + transform: createComponentNameAnnotateHooks(ignoredComponents, injectIntoHtml).transform, }); }