From d714e2e0f4acbcbbf1ad00a09227887aea152d4a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 25 Jun 2026 11:13:21 +0200 Subject: [PATCH 1/8] Auto wrap Expo Router ErrorBoundary --- CHANGELOG.md | 3 + packages/core/src/js/tools/metroconfig.ts | 40 ++++-- .../js/tools/sentryBabelTransformerUtils.ts | 16 +++ .../sentryExpoRouterAutoWrapBabelPlugin.ts | 120 ++++++++++++++++++ packages/core/test/tools/metroconfig.test.ts | 1 + ...entryExpoRouterAutoWrapBabelPlugin.test.ts | 63 +++++++++ samples/expo/app/_layout.tsx | 9 +- 7 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts create mode 100644 packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ec555b33..c78a0cf892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ ``` Render-phase errors that reach the boundary are captured with route context (`route.name`, `route.path`, `route.params`), the in-flight navigation transaction is tagged as errored, and a breadcrumb is emitted. Concrete paths and params are gated behind `sendDefaultPii`. +- Auto-wrap Expo Router's `ErrorBoundary` re-exports at build time + + `getSentryExpoConfig` now ships a Babel plugin that rewrites `export { ErrorBoundary } from 'expo-router'` into a wrapped re-export, so the boundary is auto-instrumented without changing your route files. Opt out with `autoWrapExpoRouterErrorBoundary: false`. For non-Expo Metro setups, `withSentryConfig` exposes the same option (off by default). ### Fixes diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index e7a92c3860..2d59cb1ccf 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 for `withSentryConfig`, true for `getSentryExpoConfig` + */ + 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 ?? true; + 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,10 @@ export function withSentryBabelTransformer( setSentryDefaultBabelTransformerPathEnv(defaultBabelTransformerPath); } - if (typeof annotateReactComponents === 'object') { - setSentryBabelTransformerOptions({ - annotateReactComponents, - }); - } + setSentryBabelTransformerOptions({ + ...(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..42248d7b5d 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'; @@ -98,6 +101,9 @@ export function createSentryBabelTransformer(): BabelTransformer { const transformerArgs = args[0]; addSentryComponentAnnotatePlugin(transformerArgs, options?.annotateReactComponents); + if (options?.autoWrapExpoRouterErrorBoundary) { + addSentryExpoRouterAutoWrapPlugin(transformerArgs); + } return defaultTransformer.transform(...args); }; @@ -124,3 +130,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..bcec4e6e25 --- /dev/null +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +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 idempotent: a file that has already been transformed + * (recognised by the marker import) is left alone on subsequent runs. + * + * 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; + } + + // Idempotency: if we've already injected the wrap import in this file, + // don't re-transform any re-exports below it. + const program = path.findParent(p => p.isProgram()) as NodePath | null; + if (program && hasMarkerImport(t, program)) { + 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; + + // Build the replacement nodes. + const originalImport = t.importDeclaration( + [t.importSpecifier(t.identifier(ORIGINAL_BOUNDARY_LOCAL), t.identifier(BOUNDARY_EXPORT))], + t.stringLiteral(EXPO_ROUTER_PACKAGE), + ); + const wrapImport = t.importDeclaration( + [t.importSpecifier(t.identifier(WRAP_FN_LOCAL), t.identifier('wrapExpoRouterErrorBoundary'))], + t.stringLiteral(SENTRY_PACKAGE), + ); + const wrappedExport = t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(exportedName), + t.callExpression(t.identifier(WRAP_FN_LOCAL), [t.identifier(ORIGINAL_BOUNDARY_LOCAL)]), + ), + ]), + [], + ); + + const remainingSpecifiers = node.specifiers.filter((_, i) => i !== boundarySpecifierIndex); + + const replacements: BabelTypes.Statement[] = [originalImport, wrapImport, wrappedExport]; + if (remainingSpecifiers.length > 0) { + replacements.push(t.exportNamedDeclaration(null, remainingSpecifiers, t.cloneNode(node.source))); + } + + path.replaceWithMultiple(replacements); + }, + }, + }; +} + +function hasMarkerImport(t: typeof BabelTypes, program: NodePath): boolean { + return program.node.body.some(stmt => { + if (!t.isImportDeclaration(stmt) || stmt.source.value !== SENTRY_PACKAGE) { + return false; + } + return stmt.specifiers.some( + s => t.isImportSpecifier(s) && t.isIdentifier(s.local) && s.local.name === WRAP_FN_LOCAL, + ); + }); +} 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/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts new file mode 100644 index 0000000000..c13493ae08 --- /dev/null +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -0,0 +1,63 @@ +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).toContain( + `export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, + ); + }); + + it('preserves the user-chosen name on aliased re-exports', () => { + const out = transform(`export { ErrorBoundary as RouteErrorBoundary } from 'expo-router';`); + expect(out).toContain( + `export const RouteErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, + ); + }); + + it('keeps sibling specifiers untouched on mixed re-exports', () => { + const out = transform(`export { ErrorBoundary, Stack } from 'expo-router';`); + expect(out).toContain( + `export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, + ); + expect(out).toMatch(/export\s*\{\s*Stack\s*\}\s*from\s*['"]expo-router['"]/); + }); + + 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('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('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..e4be8a8505 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,10 @@ 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. `getSentryExpoConfig` auto +// wraps it at build time with `Sentry.wrapExpoRouterErrorBoundary` so +// render-phase errors that hit the fallback are captured with route context. +export { ErrorBoundary } from 'expo-router'; LogBox.ignoreAllLogs(); From 805c68ad23dc0abd49fb7efc7baecab9272edabb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 25 Jun 2026 12:27:49 +0200 Subject: [PATCH 2/8] fix(core): Don't auto-inject component-annotate plugin when only auto-wrap is enabled Before this fix, enabling `autoWrapExpoRouterErrorBoundary` (including the default-on path for `getSentryExpoConfig`) silently activated the `@sentry/babel-plugin-component-annotate` plugin for every non-`node_modules` file, even though the user never opted in to component annotation. Root cause: `withSentryBabelTransformer` used to be reachable only when `annotateReactComponents` was truthy, so `addSentryComponentAnnotatePlugin` inside the transformer was effectively gated by that. Adding the auto-wrap path opened a second route that bypassed that gate. Two changes: 1. Persist an explicit `annotateReactComponents` marker in the transformer options whenever the user requested it (using `{}` for the boolean-`true` case), so the opt-in signal actually survives the env-variable handoff to the Babel transformer worker. 2. Guard the call site in `createSentryBabelTransformer` so the annotate plugin is only added when the persisted options carry an `annotateReactComponents` key. No opt-in, no plugin. The 3 existing transformer tests that codified the latent bug (plugin added when no options were persisted) are updated to set the opt-in explicitly. Adds a regression test asserting that enabling only `autoWrapExpoRouterErrorBoundary` does not pull in the annotate plugin. --- packages/core/src/js/tools/metroconfig.ts | 4 +- .../js/tools/sentryBabelTransformerUtils.ts | 4 +- .../test/tools/sentryBabelTransformer.test.ts | 38 ++++++++++++++----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 2d59cb1ccf..e492aa7b52 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -243,7 +243,9 @@ export function withSentryBabelTransformer( } setSentryBabelTransformerOptions({ - ...(typeof annotateReactComponents === 'object' ? { annotateReactComponents } : {}), + ...(annotateReactComponents + ? { annotateReactComponents: typeof annotateReactComponents === 'object' ? annotateReactComponents : {} } + : {}), autoWrapExpoRouterErrorBoundary, }); diff --git a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts index 42248d7b5d..a92bffa21d 100644 --- a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts +++ b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts @@ -100,7 +100,9 @@ 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); } 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()]]), ); }); From 8a94fea99f643d86dba2580c9a99a6b88beb919c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 25 Jun 2026 14:15:51 +0200 Subject: [PATCH 3/8] refactor(core): Address PR review feedback - Switch `autoWrapExpoRouterErrorBoundary` to default `false` for both `getSentryExpoConfig` and `withSentryConfig`. Defaulting on was a blast-radius concern \u2014 every existing `getSentryExpoConfig` user would pick up the Babel pass silently on upgrade. Opt-in is consistent with the existing `annotateReactComponents` precedent and lets us flip the default on later once the transform has shipped without incident. - Drop the unused `/* eslint-disable @typescript-eslint/no-explicit-any */` directive at the top of `sentryExpoRouterAutoWrapBabelPlugin.ts`. The plugin no longer uses `any`. - Reformat the CHANGELOG entry to match the project style: short headline with a small usage code block, and a Fixes entry covering the Babel transformer gating change (PR-referenced for the Danger check). - Wire the Expo sample to exercise the auto-wrap path through the explicit opt-in: `autoWrapExpoRouterErrorBoundary: true` in `samples/expo/metro.config.js`, route `_layout.tsx` back to the bare `export { ErrorBoundary } from 'expo-router'` re-export so the build-time transform is actually exercised in CI. --- CHANGELOG.md | 17 ++++++++++++++--- packages/core/src/js/tools/metroconfig.ts | 4 ++-- .../sentryExpoRouterAutoWrapBabelPlugin.ts | 1 - samples/expo/app/_layout.tsx | 7 ++++--- samples/expo/metro.config.js | 3 +++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c78a0cf892..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 @@ -35,9 +49,6 @@ ``` Render-phase errors that reach the boundary are captured with route context (`route.name`, `route.path`, `route.params`), the in-flight navigation transaction is tagged as errored, and a breadcrumb is emitted. Concrete paths and params are gated behind `sendDefaultPii`. -- Auto-wrap Expo Router's `ErrorBoundary` re-exports at build time - - `getSentryExpoConfig` now ships a Babel plugin that rewrites `export { ErrorBoundary } from 'expo-router'` into a wrapped re-export, so the boundary is auto-instrumented without changing your route files. Opt out with `autoWrapExpoRouterErrorBoundary: false`. For non-Expo Metro setups, `withSentryConfig` exposes the same option (off by default). ### Fixes diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index e492aa7b52..34464203fc 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -85,7 +85,7 @@ export interface SentryMetroConfigOptions { * wrapped boundary instead. Files that already import * `wrapExpoRouterErrorBoundary` are left untouched. * - * @default false for `withSentryConfig`, true for `getSentryExpoConfig` + * @default false */ autoWrapExpoRouterErrorBoundary?: boolean; } @@ -169,7 +169,7 @@ export function getSentryExpoConfig( }); let newConfig = withSentryFramesCollapsed(config); - const autoWrapExpoRouterErrorBoundary = options.autoWrapExpoRouterErrorBoundary ?? true; + const autoWrapExpoRouterErrorBoundary = options.autoWrapExpoRouterErrorBoundary ?? false; if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary) { newConfig = withSentryBabelTransformer( newConfig, diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index bcec4e6e25..65fd5a1806 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { NodePath, PluginObj, PluginPass, types as BabelTypes } from '@babel/core'; /** diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index e4be8a8505..a1b0da6a07 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -10,9 +10,10 @@ import { useColorScheme } from '@/components/useColorScheme'; import { SENTRY_INTERNAL_DSN } from '../utils/dsn'; -// Re-export Expo Router's per-route ErrorBoundary. `getSentryExpoConfig` auto -// wraps it at build time with `Sentry.wrapExpoRouterErrorBoundary` so -// render-phase errors that hit the fallback are captured with route context. +// 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); From e815cd36e52a8b5f671749e0082fc5973fe69dec Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 26 Jun 2026 10:26:40 +0200 Subject: [PATCH 4/8] fix(core): Wrap every ErrorBoundary re-export, not just the first The earlier program-wide `hasMarkerImport` idempotency check ran before inspecting the current node's specifiers, so after the first `export { ErrorBoundary } from 'expo-router'` was rewritten and the `@sentry/react-native` marker import was injected, any subsequent `ErrorBoundary` re-export in the same file was silently skipped and left unwrapped \u2014 Sentry would miss errors on those routes. The marker check was redundant anyway: after a successful rewrite, the re-export is no longer `export ... from 'expo-router'`, so a second pass over the same code structurally finds nothing to transform. Remove the program-wide check and the helper. Adds a regression test (`wraps every ErrorBoundary re-export when several appear in the same file`) and keeps the existing idempotency test, which still passes by structural idempotency. --- .../sentryExpoRouterAutoWrapBabelPlugin.ts | 23 +++---------------- ...entryExpoRouterAutoWrapBabelPlugin.test.ts | 9 ++++++++ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index 65fd5a1806..4eeb92cfaa 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -25,8 +25,9 @@ import type { NodePath, PluginObj, PluginPass, types as BabelTypes } from '@babe * `export { ErrorBoundary, Stack } from 'expo-router'` keep the non-boundary * specifiers in place. * - * The transform is idempotent: a file that has already been transformed - * (recognised by the marker import) is left alone on subsequent runs. + * 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. */ @@ -51,13 +52,6 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA return; } - // Idempotency: if we've already injected the wrap import in this file, - // don't re-transform any re-exports below it. - const program = path.findParent(p => p.isProgram()) as NodePath | null; - if (program && hasMarkerImport(t, program)) { - return; - } - const filename = (state.file?.opts?.filename as string | undefined) ?? ''; if (filename.includes('node_modules')) { return; @@ -106,14 +100,3 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA }, }; } - -function hasMarkerImport(t: typeof BabelTypes, program: NodePath): boolean { - return program.node.body.some(stmt => { - if (!t.isImportDeclaration(stmt) || stmt.source.value !== SENTRY_PACKAGE) { - return false; - } - return stmt.specifiers.some( - s => t.isImportSpecifier(s) && t.isIdentifier(s.local) && s.local.name === WRAP_FN_LOCAL, - ); - }); -} diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts index c13493ae08..04ca6ed2b0 100644 --- a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -48,6 +48,15 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { 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 occurrences = out.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; + expect(occurrences).toBe(2); + expect(out).toMatch(/export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary/); + expect(out).toMatch(/export const Other = __sentryWrapExpoRouterErrorBoundary/); + }); + it('is idempotent — running the plugin twice does not double-wrap', () => { const first = transform(`export { ErrorBoundary } from 'expo-router';`); const second = transform(first); From f18a2fb79aed05000cd1d75af06b8487f0d63e32 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 26 Jun 2026 10:44:12 +0200 Subject: [PATCH 5/8] fix(core): Inject helper imports only once per file After the previous fix to wrap every `ErrorBoundary` re-export, each rewrite re-emitted the helper imports. With two or more `export { ErrorBoundary } from 'expo-router'` declarations in the same file, this produced duplicate ES module import bindings \u2014 a syntax error that Metro can reject before the bundle is built. Track helper injection per file via `PluginPass.set/get`. The first wrap emits both helper imports and sets a flag; subsequent wraps in the same file reuse the existing bindings and emit only the `export const ... = __sentryWrap(...)` statement. Tightens the multi-export regression test to assert each helper import appears exactly once. --- .../sentryExpoRouterAutoWrapBabelPlugin.ts | 47 ++++++++++++------- ...entryExpoRouterAutoWrapBabelPlugin.test.ts | 12 ++++- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index 4eeb92cfaa..c0119d561a 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -69,28 +69,39 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA ? boundarySpecifier.exported.name : boundarySpecifier.exported.value; - // Build the replacement nodes. - const originalImport = t.importDeclaration( - [t.importSpecifier(t.identifier(ORIGINAL_BOUNDARY_LOCAL), t.identifier(BOUNDARY_EXPORT))], - t.stringLiteral(EXPO_ROUTER_PACKAGE), - ); - const wrapImport = t.importDeclaration( - [t.importSpecifier(t.identifier(WRAP_FN_LOCAL), t.identifier('wrapExpoRouterErrorBoundary'))], - t.stringLiteral(SENTRY_PACKAGE), - ); - const wrappedExport = t.exportNamedDeclaration( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(exportedName), - t.callExpression(t.identifier(WRAP_FN_LOCAL), [t.identifier(ORIGINAL_BOUNDARY_LOCAL)]), + // Emit the two helper imports only on the first wrap in this file. + // Multiple `export { ErrorBoundary } from 'expo-router'` declarations + // in the same file would otherwise inject duplicate module bindings, + // which is illegal in ES modules. + const HELPERS_KEY = 'sentryAutoWrapHelpersInjected'; + const helpersAlreadyInjected = state.get(HELPERS_KEY) === true; + const replacements: BabelTypes.Statement[] = []; + if (!helpersAlreadyInjected) { + replacements.push( + 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); + } + replacements.push( + t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(exportedName), + t.callExpression(t.identifier(WRAP_FN_LOCAL), [t.identifier(ORIGINAL_BOUNDARY_LOCAL)]), + ), + ]), + [], + ), ); const remainingSpecifiers = node.specifiers.filter((_, i) => i !== boundarySpecifierIndex); - - const replacements: BabelTypes.Statement[] = [originalImport, wrapImport, wrappedExport]; if (remainingSpecifiers.length > 0) { replacements.push(t.exportNamedDeclaration(null, remainingSpecifiers, t.cloneNode(node.source))); } diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts index 04ca6ed2b0..ce5e414d37 100644 --- a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -51,10 +51,18 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { 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 occurrences = out.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; - expect(occurrences).toBe(2); + const wrapCalls = out.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; + expect(wrapCalls).toBe(2); expect(out).toMatch(/export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary/); expect(out).toMatch(/export const Other = __sentryWrapExpoRouterErrorBoundary/); + // 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', () => { From 2c1b33c795aef3d736c3d34fd9732e2700bc0283 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 26 Jun 2026 10:56:54 +0200 Subject: [PATCH 6/8] fix(core): Hoist helper imports to the top of the file The plugin was using `path.replaceWithMultiple` at the original export's position, which placed the two helper `import` declarations wherever the `export { ErrorBoundary } from 'expo-router'` happened to live in the source file. If anything precedes that export (other imports, top-level constants, etc.), the helper imports end up mid-file. Babel parses that, but strict ESM environments \u2014 notably Hermes \u2014 may reject mid-file `import` statements, breaking the bundle. Use `path.scope.getProgramParent().path.unshiftContainer('body', ...)` to push the helper imports to the top of the Program body, alongside the file's existing imports. The wrap statement still replaces the original export in place. Per-file dedup via `PluginPass.set/get` is preserved so a second wrap reuses the existing bindings. Adds a regression test that constructs a file with leading imports + a top-level statement before the boundary re-export and asserts both helper imports land above any non-import statement. --- .../sentryExpoRouterAutoWrapBabelPlugin.ts | 23 ++++++++-------- ...entryExpoRouterAutoWrapBabelPlugin.test.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index c0119d561a..e17b25dacc 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -69,15 +69,15 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA ? boundarySpecifier.exported.name : boundarySpecifier.exported.value; - // Emit the two helper imports only on the first wrap in this file. - // Multiple `export { ErrorBoundary } from 'expo-router'` declarations - // in the same file would otherwise inject duplicate module bindings, - // which is illegal in ES modules. + // 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'; - const helpersAlreadyInjected = state.get(HELPERS_KEY) === true; - const replacements: BabelTypes.Statement[] = []; - if (!helpersAlreadyInjected) { - replacements.push( + 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), @@ -86,10 +86,11 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA [t.importSpecifier(t.identifier(WRAP_FN_LOCAL), t.identifier('wrapExpoRouterErrorBoundary'))], t.stringLiteral(SENTRY_PACKAGE), ), - ); + ]); state.set(HELPERS_KEY, true); } - replacements.push( + + const replacements: BabelTypes.Statement[] = [ t.exportNamedDeclaration( t.variableDeclaration('const', [ t.variableDeclarator( @@ -99,7 +100,7 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA ]), [], ), - ); + ]; const remainingSpecifiers = node.specifiers.filter((_, i) => i !== boundarySpecifierIndex); if (remainingSpecifiers.length > 0) { diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts index ce5e414d37..acf57388d0 100644 --- a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -41,6 +41,33 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { expect(out).toMatch(/export\s*\{\s*Stack\s*\}\s*from\s*['"]expo-router['"]/); }); + 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); From e4d2b88a28adcab90614a232bde3a78a995192ae Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 26 Jun 2026 11:50:17 +0200 Subject: [PATCH 7/8] fix(core): Use a fresh local for the wrapped boundary to avoid binding clashes If the user's file already has an `ErrorBoundary` top-level binding (e.g. `import { ErrorBoundary } from 'expo-router'` used locally), emitting `export const ErrorBoundary = __sentryWrap(...)` produces a duplicate top-level binding and breaks the Metro/Babel build. Generate a unique local via `path.scope.generateUidIdentifier` for the wrapped value and export it under the original name through an `export { uid as ErrorBoundary }` specifier instead of a const-with-name declaration. The exported name Expo Router consumes is unchanged; only the internal binding is now collision-free. Adds a regression test `does not clash with an existing local ErrorBoundary binding in the same file` and updates the other tests to match the new emission shape (`const _wrappedErrorBoundary = ...; export { ... as ErrorBoundary };`). --- .../sentryExpoRouterAutoWrapBabelPlugin.ts | 22 ++++++++----- ...entryExpoRouterAutoWrapBabelPlugin.test.ts | 33 +++++++++++++------ 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index e17b25dacc..5876e1279e 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -90,16 +90,20 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA 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.exportNamedDeclaration( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(exportedName), - t.callExpression(t.identifier(WRAP_FN_LOCAL), [t.identifier(ORIGINAL_BOUNDARY_LOCAL)]), - ), - ]), - [], - ), + 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); diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts index acf57388d0..1d366bd508 100644 --- a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -21,26 +21,39 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { expect(out).toMatch( /import\s*\{\s*wrapExpoRouterErrorBoundary as __sentryWrapExpoRouterErrorBoundary\s*\}\s*from\s*['"]@sentry\/react-native['"]/, ); - expect(out).toContain( - `export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, + 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).toContain( - `export const RouteErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, - ); + 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).toContain( - `export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary(__sentryOriginalExpoErrorBoundary)`, - ); + 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 @@ -80,8 +93,8 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { const out = transform(src); const wrapCalls = out.match(/__sentryWrapExpoRouterErrorBoundary\(/g)?.length ?? 0; expect(wrapCalls).toBe(2); - expect(out).toMatch(/export const ErrorBoundary = __sentryWrapExpoRouterErrorBoundary/); - expect(out).toMatch(/export const Other = __sentryWrapExpoRouterErrorBoundary/); + 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 = From aa389da86478e3c725e22bb7b7d9440ea2a394e4 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 26 Jun 2026 13:05:21 +0200 Subject: [PATCH 8/8] fix(core): Clone reused specifier nodes; lock in 'use client' preservation Two follow-ups from PR review: 1. `remainingSpecifiers` were reused directly when building the trailing `export { ... } from 'expo-router'` declaration on mixed re-exports. Babel's docs warn that reusing AST nodes across positions can corrupt the scope cache. Map through `t.cloneNode` so each new declaration owns its own specifier nodes. 2. Adds an explicit regression test for files that start with a `'use client'` directive. Babel's parser puts the directive on `Program.directives` (separate from `body`), so the existing `unshiftContainer('body', ...)` already preserves it \u2014 but the contract is worth pinning down in a test so we notice if a future refactor breaks it. --- .../src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts | 8 +++++++- .../tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts index 5876e1279e..46c629c86b 100644 --- a/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts +++ b/packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts @@ -106,7 +106,13 @@ export default function sentryExpoRouterAutoWrapBabelPlugin({ types: t }: BabelA t.exportNamedDeclaration(null, [t.exportSpecifier(t.cloneNode(wrappedLocal), t.identifier(exportedName))]), ]; - const remainingSpecifiers = node.specifiers.filter((_, i) => i !== boundarySpecifierIndex); + 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))); } diff --git a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts index 1d366bd508..438535dec7 100644 --- a/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts +++ b/packages/core/test/tools/sentryExpoRouterAutoWrapBabelPlugin.test.ts @@ -112,6 +112,14 @@ describe('sentryExpoRouterAutoWrapBabelPlugin', () => { 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');