Expo Router ErrorBoundary auto wrapped#6347
Merged
Merged
Conversation
Contributor
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
5aff82d to
8e4f285
Compare
6 tasks
antonis
reviewed
Jun 25, 2026
antonis
reviewed
Jun 25, 2026
antonis
reviewed
Jun 25, 2026
antonis
reviewed
Jun 25, 2026
antonis
reviewed
Jun 25, 2026
antonis
left a comment
Contributor
There was a problem hiding this comment.
Left a few comments but overall looks good 🚀
807a555 to
7eab5e6
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7a3df0b. Configure here.
…-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.
- 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.
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.
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.
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.
…g 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 };`).
…ation
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.
f0371ed to
aa389da
Compare
Contributor
Author
|
@antonis I've rebased and checked the Sentry Warden / Cursor / whatever-else comments. Please, check again! :) |
📲 Install BuildsAndroid
|
antonis
approved these changes
Jun 26, 2026
Contributor
iOS (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 6176a94+dirty | 3836.50 ms | 1217.64 ms | -2618.86 ms |
| 44c8b3f+dirty | 3823.85 ms | 1207.66 ms | -2616.19 ms |
| 64630e5+dirty | 3842.70 ms | 1218.11 ms | -2624.60 ms |
| 5257d80+dirty | 3854.39 ms | 1234.28 ms | -2620.11 ms |
| 3b6e9f9+dirty | 3851.90 ms | 1233.33 ms | -2618.57 ms |
| 88735e9+dirty | 3837.02 ms | 1214.40 ms | -2622.62 ms |
| 37a2091+dirty | 3821.77 ms | 1212.34 ms | -2609.43 ms |
| 5a010b7+dirty | 3838.85 ms | 1214.98 ms | -2623.87 ms |
| 68672fc+dirty | 3841.58 ms | 1228.89 ms | -2612.69 ms |
| f3215d3+dirty | 3842.73 ms | 1219.33 ms | -2623.40 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 6176a94+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| 44c8b3f+dirty | 5.15 MiB | 6.66 MiB | 1.51 MiB |
| 64630e5+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| 5257d80+dirty | 5.15 MiB | 6.69 MiB | 1.54 MiB |
| 3b6e9f9+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| 88735e9+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| 37a2091+dirty | 5.15 MiB | 6.70 MiB | 1.54 MiB |
| 5a010b7+dirty | 5.15 MiB | 6.69 MiB | 1.54 MiB |
| 68672fc+dirty | 5.15 MiB | 6.71 MiB | 1.55 MiB |
| f3215d3+dirty | 5.15 MiB | 6.67 MiB | 1.52 MiB |
Contributor
iOS (new) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 0b1b5e3+dirty | 3820.72 ms | 1207.94 ms | -2612.78 ms |
| 5125c43+dirty | 3827.94 ms | 1208.79 ms | -2619.15 ms |
| 7887847+dirty | 3844.89 ms | 1221.67 ms | -2623.22 ms |
| acd838e+dirty | 3835.94 ms | 1215.87 ms | -2620.07 ms |
| 20fbd51+dirty | 3832.52 ms | 1206.13 ms | -2626.39 ms |
| a5d243c+dirty | 3827.92 ms | 1220.10 ms | -2607.81 ms |
| a0d8cf8+dirty | 3826.15 ms | 1213.12 ms | -2613.03 ms |
| a3265b6+dirty | 3844.26 ms | 1235.60 ms | -2608.66 ms |
| 3817909+dirty | 1210.76 ms | 1215.64 ms | 4.89 ms |
| b04af96+dirty | 3830.54 ms | 1206.11 ms | -2624.44 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 0b1b5e3+dirty | 5.15 MiB | 6.70 MiB | 1.54 MiB |
| 5125c43+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| 7887847+dirty | 4.98 MiB | 6.46 MiB | 1.48 MiB |
| acd838e+dirty | 5.15 MiB | 6.70 MiB | 1.55 MiB |
| 20fbd51+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| a5d243c+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| a0d8cf8+dirty | 5.15 MiB | 6.67 MiB | 1.51 MiB |
| a3265b6+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| 3817909+dirty | 3.38 MiB | 4.73 MiB | 1.35 MiB |
| b04af96+dirty | 4.98 MiB | 6.54 MiB | 1.56 MiB |
Contributor
Android (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5257d80+dirty | 423.37 ms | 467.54 ms | 44.17 ms |
| 1e5d96d+dirty | 519.43 ms | 543.62 ms | 24.19 ms |
| 4e0ba9c+dirty | 452.84 ms | 473.36 ms | 20.52 ms |
| 4e0b819+dirty | 420.56 ms | 470.08 ms | 49.52 ms |
| a50b33d+dirty | 500.81 ms | 532.11 ms | 31.30 ms |
| ad66da3+dirty | 468.46 ms | 533.56 ms | 65.10 ms |
| 37a2091+dirty | 407.82 ms | 441.22 ms | 33.40 ms |
| 68ae91b+dirty | 416.44 ms | 477.56 ms | 61.12 ms |
| 15d4514+dirty | 406.77 ms | 428.06 ms | 21.29 ms |
| 5a21b51+dirty | 471.42 ms | 524.22 ms | 52.80 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5257d80+dirty | 48.30 MiB | 53.58 MiB | 5.28 MiB |
| 1e5d96d+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
| 4e0ba9c+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
| 4e0b819+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
| a50b33d+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| ad66da3+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
| 37a2091+dirty | 48.30 MiB | 53.58 MiB | 5.28 MiB |
| 68ae91b+dirty | 49.74 MiB | 54.79 MiB | 5.05 MiB |
| 15d4514+dirty | 48.30 MiB | 53.60 MiB | 5.30 MiB |
| 5a21b51+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
Contributor
Android (new) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5a21b51+dirty | 505.16 ms | 539.20 ms | 34.04 ms |
| bc0d8cf+dirty | 407.66 ms | 461.35 ms | 53.69 ms |
| 7887847+dirty | 420.47 ms | 460.55 ms | 40.08 ms |
| 5125c43+dirty | 409.52 ms | 451.00 ms | 41.48 ms |
| 7436d0f+dirty | 429.58 ms | 452.52 ms | 22.94 ms |
| 038a6d7+dirty | 499.02 ms | 527.68 ms | 28.66 ms |
| 88735e9+dirty | 427.04 ms | 487.37 ms | 60.33 ms |
| d038a14+dirty | 405.08 ms | 444.36 ms | 39.28 ms |
| 20fbd51+dirty | 594.38 ms | 655.35 ms | 60.97 ms |
| 41d6254+dirty | 406.20 ms | 445.52 ms | 39.32 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5a21b51+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
| bc0d8cf+dirty | 48.30 MiB | 53.48 MiB | 5.18 MiB |
| 7887847+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
| 5125c43+dirty | 48.30 MiB | 53.54 MiB | 5.24 MiB |
| 7436d0f+dirty | 48.30 MiB | 53.60 MiB | 5.30 MiB |
| 038a6d7+dirty | 48.30 MiB | 53.60 MiB | 5.30 MiB |
| 88735e9+dirty | 49.74 MiB | 54.82 MiB | 5.07 MiB |
| d038a14+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
| 20fbd51+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
| 41d6254+dirty | 48.30 MiB | 53.60 MiB | 5.30 MiB |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

📢 Type of change
📜 Description
Adds a Babel plugin (sentryExpoRouterAutoWrapBabelPlugin) that auto-wraps Expo Router's per-route ErrorBoundary re-exports at build time, removing the manual call-site step introduced in #6318.
The plugin detects this in user source files:
and rewrites it to:
So users get the full ErrorBoundary instrumentation (route context, navigation-span error tagging, breadcrumb) with zero code changes in their route files.
💡 Motivation and Context
#6318 shipped Sentry.wrapExpoRouterErrorBoundary but required users to edit every route file that re-exports ErrorBoundary from expo-router. That edit is trivial & easy to forget on new routes.
💚 How did you test it?
📝 Checklist
sendDefaultPIIis enabled🔮 Next steps