diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2cbe160e..aa7c7fec3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Expose scope-level attributes API (`setAttribute`, `setAttributes`, `removeAttribute`) bridging to native SDKs ([#6009](https://github.com/getsentry/sentry-react-native/pull/6009)) - Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007)) - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) +- Add `includeFeedback` Metro config option to exclude `@sentry-internal/feedback` from the bundle ([#6025](https://github.com/getsentry/sentry-react-native/pull/6025)) - Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023)) ### Fixes diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 403f32fe23..aa47c50016 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -1,3 +1,4 @@ +/* oxlint-disable eslint(max-lines) */ import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver'; @@ -38,6 +39,11 @@ export interface SentryMetroConfigOptions { * @default true */ includeWebReplay?: boolean; + /** + * Adds the Sentry user feedback widget package for web. + * @default true + */ + includeFeedback?: boolean; /** * Add Sentry Metro Server Middleware which * enables the app to fetch stack frames source context. @@ -79,6 +85,7 @@ export function withSentryConfig( { annotateReactComponents = false, includeWebReplay = true, + includeFeedback = true, enableSourceContextInDevelopment = true, optionsFile = true, }: SentryMetroConfigOptions = {}, @@ -95,6 +102,9 @@ export function withSentryConfig( if (includeWebReplay === false) { newConfig = withSentryResolver(newConfig, includeWebReplay); } + if (includeFeedback === false) { + newConfig = withSentryFeedbackResolver(newConfig, includeFeedback); + } newConfig = withSentryExcludeServerOnlyResolver(newConfig); if (enableSourceContextInDevelopment) { newConfig = withSentryMiddleware(newConfig); @@ -135,6 +145,9 @@ export function getSentryExpoConfig( if (options.includeWebReplay === false) { newConfig = withSentryResolver(newConfig, options.includeWebReplay); } + if (options.includeFeedback === false) { + newConfig = withSentryFeedbackResolver(newConfig, options.includeFeedback); + } newConfig = withSentryExcludeServerOnlyResolver(newConfig); if (options.enableSourceContextInDevelopment ?? true) { @@ -230,21 +243,26 @@ type CustomResolverBeforeMetro068 = ( ) => Resolution; /** - * Includes `@sentry/replay` packages based on the `includeWebReplay` flag and current bundle `platform`. + * Builds a Metro resolver that returns `{ type: 'empty' }` for Sentry sub-packages + * matching `moduleRegex` when the user opts out on web or the platform is native. */ -export function withSentryResolver(config: MetroConfig, includeWebReplay: boolean | undefined): MetroConfig { +function buildSentryPackageExcludeResolver( + config: MetroConfig, + includePackage: boolean | undefined, + moduleRegex: RegExp, + optionName: string, +): MetroConfig { const originalResolver = config.resolver?.resolveRequest as CustomResolver | CustomResolverBeforeMetro068 | undefined; - const sentryResolverRequest: CustomResolver = ( + const resolverRequest: CustomResolver = ( context: CustomResolutionContext, moduleName: string, platform: string | null, oldMetroModuleName?: string, ) => { if ( - (includeWebReplay === false || - (includeWebReplay === undefined && (platform === 'android' || platform === 'ios'))) && - !!(oldMetroModuleName ?? moduleName).match(/@sentry(?:-internal)?\/replay/) + (includePackage === false || (includePackage === undefined && (platform === 'android' || platform === 'ios'))) && + !!(oldMetroModuleName ?? moduleName).match(moduleRegex) ) { return { type: 'empty' } as Resolution; } @@ -254,15 +272,15 @@ export function withSentryResolver(config: MetroConfig, includeWebReplay: boolea : originalResolver(context, moduleName, platform); } - // Prior 0.68, resolve context.resolveRequest is sentryResolver itself, where on later version it is the default resolver. - if (context.resolveRequest === sentryResolverRequest) { + // Prior 0.68, context.resolveRequest is resolverRequest itself, where on later version it is the default resolver. + if (context.resolveRequest === resolverRequest) { // oxlint-disable-next-line eslint(no-console) console.error( `Error: [@sentry/react-native/metro] Can not resolve the defaultResolver on Metro older than 0.68. Please follow one of the following options: - Include your resolverRequest on your metroconfig. - Update your Metro version to 0.68 or higher. -- Set includeWebReplay as true on your metro config. +- Set ${optionName} as true on your metro config. - If you are still facing issues, report the issue at http://www.github.com/getsentry/sentry-react-native/issues`, ); // Return required for test. @@ -276,11 +294,35 @@ Please follow one of the following options: ...config, resolver: { ...config.resolver, - resolveRequest: sentryResolverRequest, + resolveRequest: resolverRequest, }, }; } +/** + * Includes `@sentry/replay` packages based on the `includeWebReplay` flag and current bundle `platform`. + */ +export function withSentryResolver(config: MetroConfig, includeWebReplay: boolean | undefined): MetroConfig { + return buildSentryPackageExcludeResolver( + config, + includeWebReplay, + /@sentry(?:-internal)?\/replay/, + 'includeWebReplay', + ); +} + +/** + * Includes `@sentry-internal/feedback` packages based on the `includeFeedback` flag and current bundle `platform`. + */ +export function withSentryFeedbackResolver(config: MetroConfig, includeFeedback: boolean | undefined): MetroConfig { + return buildSentryPackageExcludeResolver( + config, + includeFeedback, + /@sentry(?:-internal)?\/feedback/, + 'includeFeedback', + ); +} + /** * Matches relative import paths to server-only AI/MCP modules within `@sentry/core`. * diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index b9b86207f6..9ca5588024 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -9,6 +9,7 @@ import { getSentryExpoConfig, withSentryBabelTransformer, withSentryExcludeServerOnlyResolver, + withSentryFeedbackResolver, withSentryFramesCollapsed, withSentryResolver, } from '../../src/js/tools/metroconfig'; @@ -364,6 +365,190 @@ describe('metroconfig', () => { } }); }); + describe('withSentryFeedbackResolver', () => { + let originalResolverMock: any; + + // @ts-expect-error Can't see type CustomResolutionContext + let contextMock: CustomResolutionContext; + let config: MetroConfig = {}; + + beforeEach(() => { + originalResolverMock = jest.fn(); + contextMock = { + resolveRequest: jest.fn(), + }; + + config = { + resolver: { + resolveRequest: originalResolverMock, + }, + }; + }); + + describe.each([ + ['new Metro', false, '0.70.0'], + ['old Metro', true, '0.67.0'], + ])('on %s', (_description, oldMetro, metroVersion) => { + beforeEach(() => { + jest.resetModules(); + // Mock metro/package.json + jest.mock('metro/package.json', () => ({ + version: metroVersion, + })); + }); + + describe.each([['@sentry-internal/feedback'], ['@sentry/feedback']])('with %s', feedbackPackage => { + test('keep Feedback when platform is web and includeFeedback is true', () => { + const modifiedConfig = withSentryFeedbackResolver(config, true); + resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'web'); + }); + + test('removes Feedback when platform is web and includeFeedback is false', () => { + const modifiedConfig = withSentryFeedbackResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'web'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('keep Feedback when platform is android and includeFeedback is true', () => { + const modifiedConfig = withSentryFeedbackResolver(config, true); + resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'android'); + }); + + test('removes Feedback when platform is android and includeFeedback is false', () => { + const modifiedConfig = withSentryFeedbackResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('removes Feedback when platform is android and includeFeedback is undefined', () => { + const modifiedConfig = withSentryFeedbackResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('keep Feedback when platform is undefined and includeFeedback is null', () => { + const modifiedConfig = withSentryFeedbackResolver(config, undefined); + resolveRequest(modifiedConfig, contextMock, feedbackPackage, null); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, null); + }); + + test('keep Feedback when platform is ios and includeFeedback is true', () => { + const modifiedConfig = withSentryFeedbackResolver(config, true); + resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'ios'); + }); + + test('removes Feedback when platform is ios and includeFeedback is false', () => { + const modifiedConfig = withSentryFeedbackResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('removes Feedback when platform is ios and includeFeedback is undefined', () => { + const modifiedConfig = withSentryFeedbackResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + }); + + test('calls originalResolver when moduleName is not @sentry-internal/feedback', () => { + const modifiedConfig = withSentryFeedbackResolver(config, true); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web'); + }); + + test('calls originalResolver when moduleName is not @sentry-internal/feedback and includeFeedback set to false', () => { + const modifiedConfig = withSentryFeedbackResolver(config, false); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web'); + }); + + test('calls default resolver on new metro resolver when originalResolver is not provided', () => { + if (oldMetro) { + return; + } + + const modifiedConfig = withSentryFeedbackResolver({ resolver: {} }, true); + const moduleName = 'some/other/module'; + const platform = 'web'; + resolveRequest(modifiedConfig, contextMock, moduleName, platform); + + ExpectToBeCalledWithMetroParameters(contextMock.resolveRequest, contextMock, moduleName, platform); + }); + + test('throws error when running on old metro and includeFeedback is set to false', () => { + if (!oldMetro) { + return; + } + + // @ts-expect-error mock. + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + const modifiedConfig = withSentryFeedbackResolver({ resolver: {} }, true); + const moduleName = 'some/other/module'; + resolveRequest(modifiedConfig, contextMock, moduleName, 'web'); + + expect(mockExit).toHaveBeenCalledWith(-1); + }); + + type CustomResolverBeforeMetro067 = ( + // @ts-expect-error Can't see type CustomResolutionContext + context: CustomResolutionContext, + realModuleName: string, + platform: string | null, + moduleName?: string, + // @ts-expect-error Can't see type CustomResolutionContext + ) => Resolution; + + function resolveRequest( + metroConfig: MetroConfig, + context: any, + moduleName: string, + platform: string | null, + // @ts-expect-error Can't see type Resolution. + ): Resolution { + if (oldMetro) { + const resolver = metroConfig.resolver?.resolveRequest as CustomResolverBeforeMetro067; + // On older Metro the resolveRequest is the creater resolver. + context.resolveRequest = resolver; + return resolver(context, `real${moduleName}`, platform, moduleName); + } + return metroConfig.resolver?.resolveRequest?.(context, moduleName, platform); + } + + function ExpectToBeCalledWithMetroParameters( + received: CustomResolverBeforeMetro067, + contextMock: CustomResolverBeforeMetro067, + moduleName: string, + platform: string | null, + ) { + if (oldMetro) { + expect(received).toHaveBeenCalledWith(contextMock, `real${moduleName}`, platform, moduleName); + } else { + expect(received).toHaveBeenCalledWith(contextMock, moduleName, platform); + } + } + }); + }); describe('withSentryExcludeServerOnlyResolver', () => { const SENTRY_CORE_ORIGIN = '/project/node_modules/@sentry/core/build/esm/index.js';