From 615a4eface3e1d26ae6a3f0ac068bf152261d527 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 21 Jan 2026 21:17:44 +0100 Subject: [PATCH] fix: Inject component annotations into HTML elements --- .../src/constants.ts | 23 + .../src/index.ts | 50 +- .../test/test-plugin.test.ts | 2345 +++++++++++++++++ packages/bundler-plugin-core/src/index.ts | 17 +- .../src/options-mapping.ts | 6 +- packages/bundler-plugin-core/src/types.ts | 7 + .../test/option-mappings.test.ts | 6 + packages/rollup-plugin/src/index.ts | 7 +- packages/vite-plugin/src/index.ts | 7 +- packages/webpack-plugin/src/webpack4and5.ts | 9 +- 10 files changed, 2463 insertions(+), 14 deletions(-) diff --git a/packages/babel-plugin-component-annotate/src/constants.ts b/packages/babel-plugin-component-annotate/src/constants.ts index 51eac57d..f0415fc9 100644 --- a/packages/babel-plugin-component-annotate/src/constants.ts +++ b/packages/babel-plugin-component-annotate/src/constants.ts @@ -30,6 +30,29 @@ export const KNOWN_INCOMPATIBLE_PLUGINS = [ "@react-navigation", ]; +export 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", +]; + export const DEFAULT_IGNORED_ELEMENTS = [ "a", "abbr", diff --git a/packages/babel-plugin-component-annotate/src/index.ts b/packages/babel-plugin-component-annotate/src/index.ts index 54163dfd..40f631fd 100644 --- a/packages/babel-plugin-component-annotate/src/index.ts +++ b/packages/babel-plugin-component-annotate/src/index.ts @@ -35,7 +35,11 @@ import type * as Babel from "@babel/core"; import type { PluginObj, PluginPass } from "@babel/core"; -import { DEFAULT_IGNORED_ELEMENTS, KNOWN_INCOMPATIBLE_PLUGINS } from "./constants"; +import { + DEFAULT_IGNORED_ELEMENTS, + REACT_NATIVE_ELEMENTS, + KNOWN_INCOMPATIBLE_PLUGINS, +} from "./constants"; const webComponentName = "data-sentry-component"; const webElementName = "data-sentry-element"; @@ -49,6 +53,7 @@ interface AnnotationOpts { native?: boolean; "annotate-fragments"?: boolean; ignoredComponents?: string[]; + experimentalInjectIntoHtml?: boolean; } interface FragmentContext { @@ -79,6 +84,8 @@ interface JSXProcessingContext { ignoredComponents: string[]; /** Fragment context for identifying React fragments */ fragmentContext?: FragmentContext; + /** Whether to experimentally inject attributes into HTML elements */ + experimentalInjectIntoHtml?: boolean; } // We must export the plugin as default, otherwise the Babel loader will not be able to resolve it when configured using its string identifier @@ -168,6 +175,7 @@ function createJSXProcessingContext( attributeNames: attributeNamesFromState(state), ignoredComponents: state.opts.ignoredComponents ?? [], fragmentContext: state.sentryFragmentContext, + experimentalInjectIntoHtml: state.opts.experimentalInjectIntoHtml === true, }; } @@ -305,6 +313,31 @@ function processJSX( }); } +/** + * 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; +} + /** * Applies Sentry tracking attributes to a JSX opening element. * Adds component name, element name, and source file attributes while @@ -315,7 +348,14 @@ function applyAttributes( openingElement: Babel.NodePath, componentName: string ): void { - const { t, attributeNames, ignoredComponents, fragmentContext, sourceFileName } = context; + const { + t, + attributeNames, + ignoredComponents, + fragmentContext, + sourceFileName, + experimentalInjectIntoHtml, + } = context; const [componentAttributeName, elementAttributeName, sourceFileAttributeName] = attributeNames; // e.g., Raw JSX text like the `A` in `

a

` @@ -332,6 +372,12 @@ function applyAttributes( if (!openingElement.node.attributes) openingElement.node.attributes = []; const elementName = getPathName(t, openingElement); + // When experimentalInjectIntoHtml is true, only inject into HTML elements + // Skip React components + if (experimentalInjectIntoHtml && !isHtmlElement(elementName)) { + return; + } + const isAnIgnoredComponent = ignoredComponents.some( (ignoredComponent) => ignoredComponent === componentName || ignoredComponent === elementName ); diff --git a/packages/babel-plugin-component-annotate/test/test-plugin.test.ts b/packages/babel-plugin-component-annotate/test/test-plugin.test.ts index a453da77..5c3d13bb 100644 --- a/packages/babel-plugin-component-annotate/test/test-plugin.test.ts +++ b/packages/babel-plugin-component-annotate/test/test-plugin.test.ts @@ -1780,3 +1780,2348 @@ function AnotherComponent() { expect(result?.code).toMatchSnapshot(); }); }); + +describe("experimentalInjectIntoHtml: true", () => { + it("only annotates root HTML elements, not nested children", () => { + const result = transform( + `import React from 'react'; + +export default function TestComponent() { + return ( +
+
+

Title

+

Subtitle

+
+
+
+
+

Article Title

+

Article content

+
+
+
+
+ Footer text +
+
+ ); +}`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { experimentalInjectIntoHtml: true }]], + } + ); + + // Root div should have component annotation + expect(result?.code).toContain('"data-sentry-component": "TestComponent"'); + + // Child elements should NOT have any data-sentry attributes + expect(result?.code).not.toContain('"data-sentry-element": "header"'); + expect(result?.code).not.toContain('"data-sentry-element": "h1"'); + expect(result?.code).not.toContain('"data-sentry-element": "p"'); + expect(result?.code).not.toContain('"data-sentry-element": "main"'); + expect(result?.code).not.toContain('"data-sentry-element": "section"'); + expect(result?.code).not.toContain('"data-sentry-element": "article"'); + expect(result?.code).not.toContain('"data-sentry-element": "h2"'); + expect(result?.code).not.toContain('"data-sentry-element": "footer"'); + expect(result?.code).not.toContain('"data-sentry-element": "span"'); + + // Should not have source file on nested elements + const sourceFileMatches = result?.code?.match(/"data-sentry-source-file"/g); + expect(sourceFileMatches?.length).toBe(1); // Only on root element + + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + export default function TestComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + className: \\"root\\", + \\"data-sentry-component\\": \\"TestComponent\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(\\"header\\", null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Title\\"), /*#__PURE__*/React.createElement(\\"p\\", null, \\"Subtitle\\")), /*#__PURE__*/React.createElement(\\"main\\", null, /*#__PURE__*/React.createElement(\\"section\\", null, /*#__PURE__*/React.createElement(\\"article\\", null, /*#__PURE__*/React.createElement(\\"h2\\", null, \\"Article Title\\"), /*#__PURE__*/React.createElement(\\"p\\", null, \\"Article content\\")))), /*#__PURE__*/React.createElement(\\"footer\\", null, /*#__PURE__*/React.createElement(\\"span\\", null, \\"Footer text\\"))); + }" + `); + }); + + 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, { experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(\\"bogus\\", { + \\"data-sentry-element\\": \\"bogus\\", + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__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, { experimentalInjectIntoHtml: 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("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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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-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, { experimentalInjectIntoHtml: true }]], + } + ); + 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-react-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + } + } + 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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component, Fragment } from 'react'; + const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, \\"Hello world\\"); + export default componentName;" + `); + }); + + it("arrow-noreturn-annotate-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + export default componentName;" + `); + }); + + it("arrow-noreturn-annotate-react-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + export default componentName;" + `); + }); + + it("arrow-noreturn-annotate-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + export default componentName;" + `); + }); + + it("arrow-noreturn-annotate-fragment-once snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + +

Hello world

+

Hola Sol

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\"), /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hola Sol\\")); + export default componentName;" + `); + }); + + it("arrow-noreturn-annotate-fragment-no-whitespace snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( +

Hello world

Hola Sol

+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\"), /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hola Sol\\")); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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-annotate-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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-annotate-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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-annotate-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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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-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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, experimentalInjectIntoHtml: 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\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__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, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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-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, { experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + 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("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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, { experimentalInjectIntoHtml: true }]], + } + ); + 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, experimentalInjectIntoHtml: 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, experimentalInjectIntoHtml: true }]], + } + ); + expect(result?.code).toMatchInlineSnapshot(` + "import React from 'react'; + class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + dataSentryComponent: \\"PureComponentName\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } + } + export default PureComponentName;" + `); + }); + + it("Bananas incompatible plugin @react-navigation source snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "test/node_modules/@react-navigation/core/filename-test.js", + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, experimentalInjectIntoHtml: true }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + 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\\" + }); + } + }" + `); + }); + + it("skips components marked in ignoredComponents", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { native: true, ignoredComponents: ["Bananas"], experimentalInjectIntoHtml: 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\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__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, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + 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"], + experimentalInjectIntoHtml: true, + }, + ], + ], + } + ); + + // 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__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"], experimentalInjectIntoHtml: true }, + ], + ], + } + ); + + // With experimentalInjectIntoHtml: true, React components should not 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).not.toContain('"data-sentry-element": "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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Components.UI.Button, null, \\"Click me\\"), /*#__PURE__*/React.createElement(Components.UI.Card.Header, null, \\"Title\\")); + }" + `); + }); + + 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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"Content in JSX fragment\\"), /*#__PURE__*/React.createElement(\\"span\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { "annotate-fragments": false, experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { "annotate-fragments": true, experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__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, { experimentalInjectIntoHtml: true }]], + } + ); + + 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\\", null, \\"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\\", null, \\"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..89f42a9a 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -33,7 +33,10 @@ type LegacyPlugins = { interface SentryUnpluginFactoryOptions { injectionPlugin: InjectionPlugin | LegacyPlugins; - componentNameAnnotatePlugin?: (ignoredComponents?: string[]) => UnpluginOptions; + componentNameAnnotatePlugin?: ( + injectIntoHtml: boolean, + ignoredComponents?: string[] + ) => UnpluginOptions; debugIdUploadPlugin: ( upload: (buildArtifacts: string[]) => Promise, logger: Logger, @@ -190,7 +193,10 @@ export function sentryUnpluginFactory({ } else { componentNameAnnotatePlugin && plugins.push( - componentNameAnnotatePlugin(options.reactComponentAnnotation.ignoredComponents) + componentNameAnnotatePlugin( + options.reactComponentAnnotation.injectIntoHtml, + options.reactComponentAnnotation.ignoredComponents + ) ); } } @@ -391,7 +397,10 @@ export function createRollupDebugIdUploadHooks( }; } -export function createComponentNameAnnotateHooks(ignoredComponents?: string[]): { +export function createComponentNameAnnotateHooks( + injectIntoHtml: boolean, + ignoredComponents?: string[] +): { transform: UnpluginOptions["transform"]; } { type ParserPlugins = NonNullable< @@ -421,7 +430,7 @@ export function createComponentNameAnnotateHooks(ignoredComponents?: string[]): try { const result = await transformAsync(code, { - plugins: [[componentNameAnnotatePlugin, { ignoredComponents }]], + plugins: [[componentNameAnnotatePlugin, { injectIntoHtml, 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..07b56489 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[]; + injectIntoHtml: boolean; } | undefined; _metaOptions: { @@ -116,7 +117,10 @@ export function normalizeUserOptions(userOptions: UserOptions): NormalizedOption | undefined, }, bundleSizeOptimizations: userOptions.bundleSizeOptimizations, - reactComponentAnnotation: userOptions.reactComponentAnnotation, + reactComponentAnnotation: { + ...userOptions.reactComponentAnnotation, + injectIntoHtml: !!userOptions.reactComponentAnnotation?._experimentalInjectIntoHtml, + }, _metaOptions: { telemetry: { metaFramework: userOptions._metaOptions?.telemetry?.metaFramework, diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index f86aca5e..94bc1d35 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -354,6 +354,13 @@ 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[]; + /** + * If set to true, the plugin will inject attributes into any HTML elements + * inside React fragments at the root level. + * + * Defaults to `false`. + */ + _experimentalInjectIntoHtml?: boolean; }; /** diff --git a/packages/bundler-plugin-core/test/option-mappings.test.ts b/packages/bundler-plugin-core/test/option-mappings.test.ts index 921bcd1e..9bcc5d5e 100644 --- a/packages/bundler-plugin-core/test/option-mappings.test.ts +++ b/packages/bundler-plugin-core/test/option-mappings.test.ts @@ -16,6 +16,9 @@ describe("normalizeUserOptions()", () => { project: "my-project", debug: false, disable: false, + reactComponentAnnotation: { + injectIntoHtml: false, + }, release: { name: "my-release", finalize: true, @@ -66,6 +69,9 @@ describe("normalizeUserOptions()", () => { project: "my-project", debug: false, disable: false, + reactComponentAnnotation: { + injectIntoHtml: false, + }, release: { name: "my-release", vcsRemote: "origin", diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts index 30ad55fd..7529a977 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( + injectIntoHtml: boolean, + ignoredComponents?: string[] +): UnpluginOptions { return { name: "sentry-rollup-component-name-annotate-plugin", - rollup: createComponentNameAnnotateHooks(ignoredComponents), + rollup: createComponentNameAnnotateHooks(injectIntoHtml, ignoredComponents), }; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index a6bc4ac7..2d55f05a 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( + injectIntoHtml: boolean, + ignoredComponents?: string[] +): UnpluginOptions { return { name: "sentry-vite-component-name-annotate-plugin", enforce: "pre" as const, - vite: createComponentNameAnnotateHooks(ignoredComponents), + vite: createComponentNameAnnotateHooks(injectIntoHtml, ignoredComponents), }; } diff --git a/packages/webpack-plugin/src/webpack4and5.ts b/packages/webpack-plugin/src/webpack4and5.ts index aaf8bf5f..3db51ed6 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(): ( + injectIntoHtml: boolean, + ignoredComponents?: string[] +) => UnpluginOptions { + return (injectIntoHtml: boolean, ignoredComponents?: string[]) => ({ 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(injectIntoHtml, ignoredComponents).transform, }); }