diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ec555b33..c3ebcb3f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ ### Features - Use the runtime's native `btoa` for envelope base64 encoding when available, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if `btoa` is missing ([#6351](https://github.com/getsentry/sentry-react-native/pull/6351)). +- Opt-in build-time auto-wrap for Expo Router's per-route `ErrorBoundary` ([#6347](https://github.com/getsentry/sentry-react-native/pull/6347)) + + Enable `autoWrapExpoRouterErrorBoundary: true` in `getSentryExpoConfig` (or `withSentryConfig`) and the Sentry Babel plugin rewrites `export { ErrorBoundary } from 'expo-router'` into a `wrapExpoRouterErrorBoundary` call at build time — no route-file edits needed: + + ```js + // metro.config.js + module.exports = getSentryExpoConfig(__dirname, { + autoWrapExpoRouterErrorBoundary: true, + }); + ``` + +### Fixes + +- The Sentry Babel transformer no longer injects `@sentry/babel-plugin-component-annotate` unless `annotateReactComponents` is explicitly enabled ([#6347](https://github.com/getsentry/sentry-react-native/pull/6347)) ### Dependencies diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index e7a92c3860..34464203fc 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -74,6 +74,20 @@ export interface SentryMetroConfigOptions { * @default '{projectRoot}/sentry.options.json' */ optionsFile?: string | boolean; + /** + * Auto-wrap Expo Router's per-route `ErrorBoundary` re-exports with + * `Sentry.wrapExpoRouterErrorBoundary` at build time so render-phase errors + * that hit the fallback are captured without requiring the user to change + * their route files. + * + * Detects `export { ErrorBoundary } from 'expo-router'` (and aliased + * variants) in non-`node_modules` files and rewrites them to import the + * wrapped boundary instead. Files that already import + * `wrapExpoRouterErrorBoundary` are left untouched. + * + * @default false + */ + autoWrapExpoRouterErrorBoundary?: boolean; } export interface SentryExpoConfigOptions { @@ -104,6 +118,7 @@ export function withSentryConfig( includeWebFeedback = true, enableSourceContextInDevelopment = true, optionsFile = true, + autoWrapExpoRouterErrorBoundary = false, }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -112,8 +127,8 @@ export function withSentryConfig( newConfig = withSentryDebugId(newConfig); newConfig = withSentryFramesCollapsed(newConfig); - if (annotateReactComponents) { - newConfig = withSentryBabelTransformer(newConfig, annotateReactComponents); + if (annotateReactComponents || autoWrapExpoRouterErrorBoundary) { + newConfig = withSentryBabelTransformer(newConfig, annotateReactComponents, autoWrapExpoRouterErrorBoundary); } if (includeWebReplay === false) { newConfig = withSentryResolver(newConfig, includeWebReplay); @@ -154,8 +169,13 @@ export function getSentryExpoConfig( }); let newConfig = withSentryFramesCollapsed(config); - if (options.annotateReactComponents) { - newConfig = withSentryBabelTransformer(newConfig, options.annotateReactComponents); + const autoWrapExpoRouterErrorBoundary = options.autoWrapExpoRouterErrorBoundary ?? false; + if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary) { + newConfig = withSentryBabelTransformer( + newConfig, + options.annotateReactComponents ?? false, + autoWrapExpoRouterErrorBoundary, + ); } if (options.includeWebReplay === false) { @@ -202,8 +222,9 @@ function loadExpoMetroConfigModule(): { export function withSentryBabelTransformer( config: MetroConfig, annotateReactComponents: - | true + | boolean | { ignoredComponents?: string[]; autoInjectSentryLabel?: boolean; textComponentNames?: string[] }, + autoWrapExpoRouterErrorBoundary: boolean = false, ): MetroConfig { const defaultBabelTransformerPath = config.transformer?.babelTransformerPath; debug.log('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath); @@ -221,11 +242,12 @@ export function withSentryBabelTransformer( setSentryDefaultBabelTransformerPathEnv(defaultBabelTransformerPath); } - if (typeof annotateReactComponents === 'object') { - setSentryBabelTransformerOptions({ - annotateReactComponents, - }); - } + setSentryBabelTransformerOptions({ + ...(annotateReactComponents + ? { annotateReactComponents: typeof annotateReactComponents === 'object' ? annotateReactComponents : {} } + : {}), + autoWrapExpoRouterErrorBoundary, + }); return { ...config, diff --git a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts index fc20576816..a92bffa21d 100644 --- a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts +++ b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts @@ -4,12 +4,15 @@ import * as process from 'process'; import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; +import sentryExpoRouterAutoWrapBabelPlugin from './sentryExpoRouterAutoWrapBabelPlugin'; + export type SentryBabelTransformerOptions = { annotateReactComponents?: { ignoredComponents?: string[]; autoInjectSentryLabel?: boolean; textComponentNames?: string[]; }; + autoWrapExpoRouterErrorBoundary?: boolean; }; export const SENTRY_DEFAULT_BABEL_TRANSFORMER_PATH = 'SENTRY_DEFAULT_BABEL_TRANSFORMER_PATH'; @@ -97,7 +100,12 @@ export function createSentryBabelTransformer(): BabelTransformer { const transform: BabelTransformer['transform'] = (...args) => { const transformerArgs = args[0]; - addSentryComponentAnnotatePlugin(transformerArgs, options?.annotateReactComponents); + if (options?.annotateReactComponents !== undefined) { + addSentryComponentAnnotatePlugin(transformerArgs, options.annotateReactComponents); + } + if (options?.autoWrapExpoRouterErrorBoundary) { + addSentryExpoRouterAutoWrapPlugin(transformerArgs); + } return defaultTransformer.transform(...args); }; @@ -124,3 +132,13 @@ function addSentryComponentAnnotatePlugin( args.plugins.push([componentAnnotatePlugin, pluginOptions]); } } + +function addSentryExpoRouterAutoWrapPlugin(args: BabelTransformerArgs | undefined): void { + if (!args || typeof args.filename !== 'string' || !Array.isArray(args.plugins)) { + return undefined; + } + if (args.filename.includes('node_modules')) { + return undefined; + } + args.plugins.push([sentryExpoRouterAutoWrapBabelPlugin, {}]); +} diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts new file mode 100644 index 0000000000..46c629c86b --- /dev/null +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -0,0 +1,124 @@ +import type { NodePath, PluginObj, PluginPass, types as BabelTypes } from '@babel/core'; + +/** + * Babel plugin that auto-wraps Expo Router's per-route `ErrorBoundary` so the + * Sentry SDK captures render-phase errors that hit the fallback without + * requiring the user to change their route file. + * + * It rewrites: + * + * ```ts + * export { ErrorBoundary } from 'expo-router'; + * ``` + * + * into: + * + * ```ts + * import { ErrorBoundary as __sentryOriginalExpoErrorBoundary } from 'expo-router'; + * import { wrapExpoRouterErrorBoundary as __sentryWrapExpoRouterErrorBoundary } from '@sentry/react-native'; + * export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary); + * ``` + * + * Aliased re-exports (`export { ErrorBoundary as Foo } from 'expo-router'`) + * are preserved — the wrapped export keeps the user-chosen name. Mixed + * re-exports such as + * `export { ErrorBoundary, Stack } from 'expo-router'` keep the non-boundary + * specifiers in place. + * + * The transform is structurally idempotent: after the rewrite the re-export + * is no longer an `export ... from 'expo-router'`, so a second pass over the + * same file finds nothing to transform. + * + * Files inside `node_modules` are never transformed. + */ + +const ORIGINAL_BOUNDARY_LOCAL = '__sentryOriginalExpoErrorBoundary'; +const WRAP_FN_LOCAL = '__sentryWrapExpoRouterErrorBoundary'; +const SENTRY_PACKAGE = '@sentry/react-native'; +const EXPO_ROUTER_PACKAGE = 'expo-router'; +const BOUNDARY_EXPORT = 'ErrorBoundary'; + +interface BabelApi { + types: typeof BabelTypes; +} + +export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelApi): PluginObj { + return { + name: 'sentry-expo-router-auto-wrap-error-boundary', + visitor: { + ExportNamedDeclaration(path: NodePath, state: PluginPass) { + const node = path.node; + if (!node.source || node.source.value !== EXPO_ROUTER_PACKAGE) { + return; + } + + const filename = (state.file?.opts?.filename as string | undefined) ?? ''; + if (filename.includes('node_modules')) { + return; + } + + const boundarySpecifierIndex = node.specifiers.findIndex( + s => t.isExportSpecifier(s) && t.isIdentifier(s.local) && s.local.name === BOUNDARY_EXPORT, + ); + if (boundarySpecifierIndex === -1) { + return; + } + + const boundarySpecifier = node.specifiers[boundarySpecifierIndex] as BabelTypes.ExportSpecifier; + const exportedName = t.isIdentifier(boundarySpecifier.exported) + ? boundarySpecifier.exported.name + : boundarySpecifier.exported.value; + + // Hoist the two helper imports to the top of the Program body so + // they sit alongside the file's other `import` declarations rather + // than landing mid-file. Some toolchains (e.g. Hermes) are strict + // about import placement, and mid-file imports are also harder to + // read. Inject once per file so a second wrap reuses the bindings. + const HELPERS_KEY = 'sentryAutoWrapHelpersInjected'; + if (state.get(HELPERS_KEY) !== true) { + const program = path.scope.getProgramParent().path as NodePath; + program.unshiftContainer('body', [ + t.importDeclaration( + [t.importSpecifier(t.identifier(ORIGINAL_BOUNDARY_LOCAL), t.identifier(BOUNDARY_EXPORT))], + t.stringLiteral(EXPO_ROUTER_PACKAGE), + ), + t.importDeclaration( + [t.importSpecifier(t.identifier(WRAP_FN_LOCAL), t.identifier('wrapExpoRouterErrorBoundary'))], + t.stringLiteral(SENTRY_PACKAGE), + ), + ]); + state.set(HELPERS_KEY, true); + } + + // Generate a unique local binding for the wrapped boundary instead of + // declaring `const = ...` directly. That avoids clashing + // with an existing top-level binding of the same name in the file + // (e.g. `import { ErrorBoundary } from 'expo-router'` used elsewhere) + // which would otherwise produce a duplicate-binding compile error. + const wrappedLocal = path.scope.generateUidIdentifier(`wrapped${exportedName}`); + const replacements: BabelTypes.Statement[] = [ + t.variableDeclaration('const', [ + t.variableDeclarator( + wrappedLocal, + t.callExpression(t.identifier(WRAP_FN_LOCAL), [t.identifier(ORIGINAL_BOUNDARY_LOCAL)]), + ), + ]), + t.exportNamedDeclaration(null, [t.exportSpecifier(t.cloneNode(wrappedLocal), t.identifier(exportedName))]), + ]; + + const remainingSpecifiers = node.specifiers + .filter((_, i) => i !== boundarySpecifierIndex) + // Clone the reused specifier nodes so we don't share AST nodes + // between the original `path.node` (about to be replaced) and the + // new export declaration — Babel docs warn that reusing nodes can + // corrupt the scope cache. + .map(s => t.cloneNode(s)); + if (remainingSpecifiers.length > 0) { + replacements.push(t.exportNamedDeclaration(null, remainingSpecifiers, t.cloneNode(node.source))); + } + + path.replaceWithMultiple(replacements); + }, + }, + }; +} diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 0bd5d264a5..bb6ebf6dd9 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -160,6 +160,7 @@ describe('metroconfig', () => { annotateReactComponents: { ignoredComponents: ['MyCustomComponent'], }, + autoWrapExpoRouterErrorBoundary: false, }), ); }); diff --git a/packages/core/test/tools/sentryBabelTransformer.test.ts b/packages/core/test/tools/sentryBabelTransformer.test.ts index 6403dde42c..361e49575b 100644 --- a/packages/core/test/tools/sentryBabelTransformer.test.ts +++ b/packages/core/test/tools/sentryBabelTransformer.test.ts @@ -31,6 +31,10 @@ describe('SentryBabelTransformer', () => { }); test('transform calls the original transformer with the annotation plugin', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + annotateReactComponents: {}, + }); + createSentryBabelTransformer().transform?.({ filename: '/project/file', options: { @@ -53,6 +57,10 @@ describe('SentryBabelTransformer', () => { }); test('transform adds plugin with autoInjectSentryLabel enabled by default', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + annotateReactComponents: {}, + }); + createSentryBabelTransformer().transform?.(createMinimalMockedTransformOptions()); expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); @@ -142,21 +150,31 @@ describe('SentryBabelTransformer', () => { ); }); - test('degrades gracefully if options can not be parsed, transform adds plugin with defaults', () => { + test('degrades gracefully if options can not be parsed, transform skips opt-in plugins', () => { process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = 'invalid json'; createSentryBabelTransformer().transform?.(createMinimalMockedTransformOptions()); expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); - expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith( - expect.objectContaining({ - plugins: expect.arrayContaining([ - [ - expect.objectContaining({ name: 'componentNameAnnotatePlugin' }), - expect.objectContaining({ autoInjectSentryLabel: true }), - ], - ]), - }), + // When persisted options are unparseable, opt-in Babel plugins must not be + // silently injected — we cannot tell what the user asked for. + const calledArgs = MockDefaultBabelTransformer.transform.mock.calls[0][0] as BabelTransformerArgs; + expect(calledArgs.plugins).not.toEqual( + expect.arrayContaining([[expect.objectContaining({ name: 'componentNameAnnotatePlugin' }), expect.anything()]]), + ); + }); + + test('does not add the annotation plugin when only autoWrapExpoRouterErrorBoundary is enabled', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + autoWrapExpoRouterErrorBoundary: true, + }); + + createSentryBabelTransformer().transform?.(createMinimalMockedTransformOptions()); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + const calledArgs = MockDefaultBabelTransformer.transform.mock.calls[0][0] as BabelTransformerArgs; + expect(calledArgs.plugins).not.toEqual( + expect.arrayContaining([[expect.objectContaining({ name: 'componentNameAnnotatePlugin' }), expect.anything()]]), ); }); diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts new file mode 100644 index 0000000000..438535dec7 --- /dev/null +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -0,0 +1,128 @@ +import { transformSync } from '@babel/core'; + +import sentryExpoRouterAutoWrapBabelPlugin from '../../src/js/tools/sentryExpoRouterAutoWrapBabelPlugin'; + +function transform(code: string, filename: string = '/app/(tabs)/index.tsx'): string { + const result = transformSync(code, { + filename, + babelrc: false, + configFile: false, + plugins: [sentryExpoRouterAutoWrapBabelPlugin], + }); + return result?.code ?? ''; +} + +describe('sentryExpoRouterAutoWrapBabelPlugin', () => { + it('wraps a plain `export { ErrorBoundary } from "expo-router"` re-export', () => { + const out = transform(`export { ErrorBoundary } from 'expo-router';`); + expect(out).toMatch( + /import\s*\{\s*ErrorBoundary as __sentryOriginalExpoErrorBoundary\s*\}\s*from\s*['"]expo-router['"]/, + ); + expect(out).toMatch( + /import\s*\{\s*wrapExpoRouterErrorBoundary as __sentryWrapExpoRouterErrorBoundary\s*\}\s*from\s*['"]@sentry\/react-native['"]/, + ); + expect(out).toMatch( + /const\s+_wrappedErrorBoundary\w*\s*=\s*__sentryWrapExpoRouterErrorBoundary\(__sentryOriginalExpoErrorBoundary\)/, + ); + expect(out).toMatch(/export\s*\{\s*_wrappedErrorBoundary\w*\s+as\s+ErrorBoundary\s*\}/); + }); + + it('preserves the user-chosen name on aliased re-exports', () => { + const out = transform(`export { ErrorBoundary as RouteErrorBoundary } from 'expo-router';`); + expect(out).toMatch(/export\s*\{\s*_wrappedRouteErrorBoundary\w*\s+as\s+RouteErrorBoundary\s*\}/); + }); + + it('keeps sibling specifiers untouched on mixed re-exports', () => { + const out = transform(`export { ErrorBoundary, Stack } from 'expo-router';`); + expect(out).toMatch(/export\s*\{\s*_wrappedErrorBoundary\w*\s+as\s+ErrorBoundary\s*\}/); + expect(out).toMatch(/export\s*\{\s*Stack\s*\}\s*from\s*['"]expo-router['"]/); + }); + + it('does not clash with an existing local `ErrorBoundary` binding in the same file', () => { + // The user uses ErrorBoundary locally AND re-exports it for Expo Router. + // Declaring `export const ErrorBoundary = ...` would duplicate the binding + // introduced by the existing `import { ErrorBoundary }` line and fail to + // compile. The wrapped value must use a unique local instead. + const src = [ + `import { ErrorBoundary } from 'expo-router';`, + `const Local = ErrorBoundary;`, + `export { ErrorBoundary } from 'expo-router';`, + ].join('\n'); + const out = transform(src); + // The wrapped value uses a fresh uid — no second `const ErrorBoundary =` is emitted. + expect(out).not.toMatch(/const\s+ErrorBoundary\s*=/); + expect(out).toMatch(/export\s*\{\s*_wrappedErrorBoundary\w*\s+as\s+ErrorBoundary\s*\}/); + }); + + it('hoists helper imports to the top of the file, never mid-file', () => { + // A common shape: imports + non-import statements + the boundary re-export. + // Mid-file `import` declarations are invalid in strict ESM environments + // (e.g. Hermes), so the helpers must be pushed up to the imports block. + const src = [ + `import { Stack } from 'expo-router';`, + `const greeting = 'hi';`, + `export { ErrorBoundary } from 'expo-router';`, + ].join('\n'); + const out = transform(src); + const lines = out + .split('\n') + .map(l => l.trim()) + .filter(Boolean); + const firstNonImport = lines.findIndex(l => !l.startsWith('import')); + const helperLineIndexes = lines + .map((l, i) => + (l.includes('__sentryOriginalExpoErrorBoundary') || l.includes('__sentryWrapExpoRouterErrorBoundary')) && + l.startsWith('import') + ? i + : -1, + ) + .filter(i => i !== -1); + expect(helperLineIndexes.length).toBe(2); + helperLineIndexes.forEach(i => expect(i).toBeLessThan(firstNonImport)); + }); + + it('leaves unrelated re-exports alone', () => { + const src = `export { Stack } from 'expo-router';\nexport { foo } from 'other-pkg';`; + const out = transform(src); + expect(out).not.toContain('__sentryWrapExpoRouterErrorBoundary'); + expect(out).not.toContain('@sentry/react-native'); + }); + + it('wraps every ErrorBoundary re-export when several appear in the same file', () => { + const src = `export { ErrorBoundary } from 'expo-router';\nexport { ErrorBoundary as Other } from 'expo-router';`; + const out = transform(src); + const wrapCalls = out.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; + expect(wrapCalls).toBe(2); + expect(out).toMatch(/export\s*\{\s*_wrappedErrorBoundary\w*\s+as\s+ErrorBoundary\s*\}/); + expect(out).toMatch(/export\s*\{\s*_wrappedOther\w*\s+as\s+Other\s*\}/); + // Helper imports must be emitted exactly once per file — duplicates are + // an ES module syntax error. + const expoImports = + out.match(/import\s*\{\s*ErrorBoundary as __sentryOriginalExpoErrorBoundary\s*\}/g)?.length ?? 0; + const sentryImports = + out.match(/import\s*\{\s*wrapExpoRouterErrorBoundary as __sentryWrapExpoRouterErrorBoundary\s*\}/g)?.length ?? 0; + expect(expoImports).toBe(1); + expect(sentryImports).toBe(1); + }); + + it('is idempotent — running the plugin twice does not double-wrap', () => { + const first = transform(`export { ErrorBoundary } from 'expo-router';`); + const second = transform(first); + const occurrences = second.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; + expect(occurrences).toBe(1); + }); + + it("preserves a leading 'use client' directive", () => { + // Helper imports must not be hoisted above the directive prologue, or + // Expo Web / RSC tooling will stop treating the file as a client module. + const src = `'use client';\nexport { ErrorBoundary } from 'expo-router';`; + const out = transform(src); + expect(out.trimStart()).toMatch(/^['"]use client['"];/); + }); + + it('skips files inside node_modules', () => { + const out = transform(`export { ErrorBoundary } from 'expo-router';`, '/proj/node_modules/expo-router/build/x.js'); + expect(out).not.toContain('@sentry/react-native'); + expect(out).toMatch(/export\s*\{\s*ErrorBoundary\s*\}\s*from\s*['"]expo-router['"]/); + }); +}); diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index c3ebf4cd57..a1b0da6a07 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import * as ImagePicker from 'expo-image-picker'; -import { ErrorBoundary as ExpoErrorBoundary, SplashScreen, Stack } from 'expo-router'; +import { SplashScreen, Stack } from 'expo-router'; import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router/react-navigation'; import { useEffect } from 'react'; import { LogBox } from 'react-native'; @@ -10,9 +10,11 @@ import { useColorScheme } from '@/components/useColorScheme'; import { SENTRY_INTERNAL_DSN } from '../utils/dsn'; -// Wrap Expo Router's per-route ErrorBoundary so render-phase errors that hit -// the fallback UI are captured by Sentry with route context attached. -export const ErrorBoundary = Sentry.wrapExpoRouterErrorBoundary(ExpoErrorBoundary); +// Re-export Expo Router's per-route ErrorBoundary. The Sentry Babel plugin +// (enabled via `autoWrapExpoRouterErrorBoundary: true` in `metro.config.js`) +// rewrites this at build time into a `Sentry.wrapExpoRouterErrorBoundary` +// call so render-phase errors that hit the fallback are captured. +export { ErrorBoundary } from 'expo-router'; LogBox.ignoreAllLogs(); diff --git a/samples/expo/metro.config.js b/samples/expo/metro.config.js index ac5a71b4c2..2af6f31fbc 100644 --- a/samples/expo/metro.config.js +++ b/samples/expo/metro.config.js @@ -12,6 +12,9 @@ const config = getSentryExpoConfig(__dirname, { annotateReactComponents: { ignoredComponents: ['BottomTabsNavigator'], }, + // Auto-wrap `export { ErrorBoundary } from 'expo-router'` so the Expo Router + // per-route boundary is captured by Sentry without route-file edits. + autoWrapExpoRouterErrorBoundary: true, }); module.exports = withMonorepo(config);