Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 32 additions & 10 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -104,6 +118,7 @@ export function withSentryConfig(
includeWebFeedback = true,
enableSourceContextInDevelopment = true,
optionsFile = true,
autoWrapExpoRouterErrorBoundary = false,
}: SentryMetroConfigOptions = {},
): MetroConfig {
setSentryMetroDevServerEnvFlag();
Expand All @@ -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);
Comment thread
alwx marked this conversation as resolved.
}
if (includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, includeWebReplay);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/js/tools/sentryBabelTransformerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Comment thread
alwx marked this conversation as resolved.
addSentryComponentAnnotatePlugin(transformerArgs, options.annotateReactComponents);
}
if (options?.autoWrapExpoRouterErrorBoundary) {
addSentryExpoRouterAutoWrapPlugin(transformerArgs);
}

return defaultTransformer.transform(...args);
};
Expand All @@ -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, {}]);
}
124 changes: 124 additions & 0 deletions packages/core/src/js/tools/sentryExpoRouterAutoWrapBabelPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<BabelTypes.ExportNamedDeclaration>, 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<BabelTypes.Program>;
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),
),
]);
Comment thread
alwx marked this conversation as resolved.
state.set(HELPERS_KEY, true);
}
Comment thread
alwx marked this conversation as resolved.

// Generate a unique local binding for the wrapped boundary instead of
// declaring `const <exportedName> = ...` 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);
Comment thread
alwx marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
},
},
};
}
1 change: 1 addition & 0 deletions packages/core/test/tools/metroconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ describe('metroconfig', () => {
annotateReactComponents: {
ignoredComponents: ['MyCustomComponent'],
},
autoWrapExpoRouterErrorBoundary: false,
}),
);
});
Expand Down
38 changes: 28 additions & 10 deletions packages/core/test/tools/sentryBabelTransformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
Expand Down Expand Up @@ -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()]]),
);
});

Expand Down
Loading
Loading