diff --git a/.buildkite/basic/react-native-android-full-pipeline.yml b/.buildkite/basic/react-native-android-full-pipeline.yml
index 465391d534..6a16c97621 100644
--- a/.buildkite/basic/react-native-android-full-pipeline.yml
+++ b/.buildkite/basic/react-native-android-full-pipeline.yml
@@ -90,7 +90,7 @@ steps:
reactnavigation: "false"
- label: ':android: Build react-native-navigation {{matrix}} test fixture APK (Old Arch)'
- skip: true # Skipped pending PLAT-15027
+ # skip: true # Skipped pending PLAT-15027
key: "build-react-native-navigation-android-fixture-old-arch"
timeout_in_minutes: 30
agents:
@@ -115,7 +115,7 @@ steps:
limit: 1
- label: ':android: Build react-native-navigation {{matrix}} test fixture APK (New Arch)'
- skip: true # Skipped pending PLAT-15027
+ #skip: true # Skipped pending PLAT-15027
key: "build-react-native-navigation-android-fixture-new-arch"
timeout_in_minutes: 30
agents:
@@ -245,7 +245,7 @@ steps:
reactnavigation: "false"
- label: ":bitbar: :android: react-native-navigation {{matrix}} Android 12 (Old Arch) end-to-end tests"
- skip: true # Skipped pending PLAT-15027
+ #skip: true # Skipped pending PLAT-15027
depends_on: "build-react-native-navigation-android-fixture-old-arch"
timeout_in_minutes: 10
plugins:
@@ -287,7 +287,7 @@ steps:
- "0.72"
- label: ":bitbar: :android: react-native-navigation {{matrix}} Android 12 (New Arch) end-to-end tests"
- skip: true # Skipped pending PLAT-15027
+ # skip: true # Skipped pending PLAT-15027
depends_on: "build-react-native-navigation-android-fixture-new-arch"
timeout_in_minutes: 10
plugins:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7a2405a89..0b0a7a6c0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## [Unreleased]
+
+### Changed
+
+- (plugin-react-native-navigation) Update react-native-navigation peer dependency to support v8 [#2727](https://github.com/bugsnag/bugsnag-js/pull/2727)
+
## [8.9.0] - 2026-04-08
### Fixed
diff --git a/package-lock.json b/package-lock.json
index 8cd64b51b3..01bfe59875 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53627,7 +53627,7 @@
},
"peerDependencies": {
"@bugsnag/core": "^8.0.0",
- "react-native-navigation": "2 - 7"
+ "react-native-navigation": "2 - 8"
},
"peerDependenciesMeta": {
"@bugsnag/core": {
diff --git a/packages/plugin-react-native-navigation/package.json b/packages/plugin-react-native-navigation/package.json
index 3b61f78a43..8e29a25057 100644
--- a/packages/plugin-react-native-navigation/package.json
+++ b/packages/plugin-react-native-navigation/package.json
@@ -24,7 +24,7 @@
},
"peerDependencies": {
"@bugsnag/core": "^8.0.0",
- "react-native-navigation": "2 - 7"
+ "react-native-navigation": "2 - 8"
},
"peerDependenciesMeta": {
"@bugsnag/core": {
diff --git a/packages/plugin-react-native-navigation/test/react-native-navigation.test.ts b/packages/plugin-react-native-navigation/test/react-native-navigation.test.ts
index eb40da2a97..4204f51d7c 100644
--- a/packages/plugin-react-native-navigation/test/react-native-navigation.test.ts
+++ b/packages/plugin-react-native-navigation/test/react-native-navigation.test.ts
@@ -189,4 +189,175 @@ describe('plugin-react-native-navigation', () => {
expect(breadcrumbs.length).toBe(0)
})
+
+ describe('navigation context tracking', () => {
+ it('tracks navigation between multiple screens', () => {
+ let listener = (event: Event) => {
+ throw new Error(`This function was not supposed to be called! ${event.componentName}`)
+ }
+
+ const Navigation = {
+ events () {
+ return {
+ registerComponentDidAppearListener (callback: (event: Event) => never) {
+ listener = callback
+ }
+ }
+ }
+ }
+
+ const plugin = new Plugin(Navigation)
+ const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] })
+
+ expect(client.getContext()).toBe(undefined)
+
+ // Navigate to Home
+ listener({ componentId: 1, componentName: 'Home', passProps: {} })
+ expect(client.getContext()).toBe('Home')
+
+ // Navigate to Profile
+ listener({ componentId: 2, componentName: 'Profile', passProps: {} })
+ expect(client.getContext()).toBe('Profile')
+
+ // Navigate to Settings
+ listener({ componentId: 3, componentName: 'Settings', passProps: {} })
+ expect(client.getContext()).toBe('Settings')
+
+ // Back to Home
+ listener({ componentId: 1, componentName: 'Home', passProps: {} })
+ expect(client.getContext()).toBe('Home')
+ })
+
+ it('handles navigation with complex component names', () => {
+ let listener = (event: Event) => {
+ throw new Error(`This function was not supposed to be called! ${event.componentName}`)
+ }
+
+ const Navigation = {
+ events () {
+ return {
+ registerComponentDidAppearListener (callback: (event: Event) => never) {
+ listener = callback
+ }
+ }
+ }
+ }
+
+ const plugin = new Plugin(Navigation)
+ const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] })
+
+ // Test with namespaced component names
+ listener({ componentId: 1, componentName: 'com.example.screens.HomeScreen', passProps: {} })
+ expect(client.getContext()).toBe('com.example.screens.HomeScreen')
+
+ // Test with screen names containing special characters
+ listener({ componentId: 2, componentName: 'User-Details-Screen', passProps: {} })
+ expect(client.getContext()).toBe('User-Details-Screen')
+ })
+ })
+
+ describe('breadcrumb metadata', () => {
+ it('includes previous screen context in breadcrumb', () => {
+ let listener = (event: Event) => {
+ throw new Error(`This function was not supposed to be called! ${event.componentName}`)
+ }
+
+ const Navigation = {
+ events () {
+ return {
+ registerComponentDidAppearListener (callback: (event: Event) => never) {
+ listener = callback
+ }
+ }
+ }
+ }
+
+ const breadcrumbs: Breadcrumb[] = []
+
+ const plugin = new Plugin(Navigation)
+ const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] })
+ client.addOnBreadcrumb(breadcrumb => { breadcrumbs.push(breadcrumb) })
+
+ listener({ componentId: 1, componentName: 'Home', passProps: {} })
+ listener({ componentId: 2, componentName: 'Details', passProps: {} })
+
+ expect(breadcrumbs.length).toBe(2)
+ expect(breadcrumbs[1].metadata).toStrictEqual({ to: 'Details', from: 'Home' })
+ })
+
+ it('sets from as undefined for initial navigation', () => {
+ let listener = (event: Event) => {
+ throw new Error(`This function was not supposed to be called! ${event.componentName}`)
+ }
+
+ const Navigation = {
+ events () {
+ return {
+ registerComponentDidAppearListener (callback: (event: Event) => never) {
+ listener = callback
+ }
+ }
+ }
+ }
+
+ const breadcrumbs: Breadcrumb[] = []
+
+ const plugin = new Plugin(Navigation)
+ const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] })
+ client.addOnBreadcrumb(breadcrumb => { breadcrumbs.push(breadcrumb) })
+
+ listener({ componentId: 1, componentName: 'Splash', passProps: {} })
+
+ expect(breadcrumbs.length).toBe(1)
+ expect(breadcrumbs[0].metadata).toStrictEqual({ to: 'Splash', from: undefined })
+ })
+ })
+
+ describe('plugin error handling', () => {
+ it('handles events without Navigation API gracefully', () => {
+ const message = '@bugsnag/plugin-react-native-navigation reference to `Navigation` was undefined'
+
+ expect(() => {
+ // eslint-disable-next-line no-new
+ new Plugin(undefined)
+ }).toThrow(new Error(message))
+ })
+
+ it('does not crash when Navigation.events is not available', () => {
+ // Create a Navigation object without the events method
+ const Navigation = {
+ events: () => ({
+ registerComponentDidAppearListener: jest.fn()
+ })
+ }
+
+ const plugin = new Plugin(Navigation)
+ const client = new Client({ apiKey: 'API_KEY_YEAH', plugins: [plugin] })
+
+ // Should not throw and client should be properly initialized
+ expect(client).toBeDefined()
+ })
+
+ it('handles missing Navigation object in plugin constructor', () => {
+ const message = '@bugsnag/plugin-react-native-navigation reference to `Navigation` was undefined'
+
+ expect(() => {
+ // eslint-disable-next-line no-new
+ new Plugin(null)
+ }).toThrow(new Error(message))
+ })
+
+ it('initializes plugin with valid Navigation API', () => {
+ const Navigation = {
+ events: () => ({
+ registerComponentDidAppearListener: jest.fn()
+ })
+ }
+
+ const plugin = new Plugin(Navigation)
+
+ // Plugin should be created successfully
+ expect(plugin).toBeDefined()
+ })
+ })
})
diff --git a/scripts/react-native/android-utils.js b/scripts/react-native/android-utils.js
index 3b8ebe857e..c3c833d93a 100644
--- a/scripts/react-native/android-utils.js
+++ b/scripts/react-native/android-utils.js
@@ -66,6 +66,102 @@ module.exports = {
fs.writeFileSync(mainActivityPath, mainActivityContents)
},
buildAPK: function buildAPK (fixtureDir, newArchEnabled) {
+ // Update Kotlin version to 1.9.22 to be compatible with bugsnag-android-core 6.25.0
+ // (compiled with Kotlin metadata version 1.9.0) and align JVM targets to 11 across
+ // all subprojects to prevent compilation mismatches (PLAT-15027)
+ const rootBuildGradlePath = `${fixtureDir}/android/build.gradle`
+ let rootBuildGradle = fs.readFileSync(rootBuildGradlePath, 'utf8')
+
+ // Determine if this is a newer RN version (0.84+) that uses Kotlin 2.x + AGP 9
+ // which handles JVM target alignment automatically
+ const rnVersion = parseFloat(process.env.RN_VERSION || '0')
+ const isKotlin2 = rnVersion >= 0.84
+
+ // Remove any existing subprojects block with afterEvaluate or pluginManager
+ rootBuildGradle = rootBuildGradle.replace(
+ /\nsubprojects\s*\{[\s\S]*?(afterEvaluate|pluginManager)[\s\S]*?\n\}\s*$/,
+ ''
+ )
+
+ if (!isKotlin2) {
+ // For RN <=0.83 (Kotlin 1.x): Update Kotlin version to 1.9.22 and add JVM target alignment
+
+ // Update RNNKotlinVersion if present (react-native-navigation fixtures)
+ rootBuildGradle = rootBuildGradle.replace(
+ /RNNKotlinVersion\s*=\s*"[^"]+"/,
+ 'RNNKotlinVersion = "1.9.22"'
+ )
+
+ // Update any direct kotlin-gradle-plugin version references in buildscript
+ rootBuildGradle = rootBuildGradle.replace(
+ /classpath\s*\(?["']org\.jetbrains\.kotlin:kotlin-gradle-plugin:[^"']+["']\)?/,
+ 'classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22")'
+ )
+
+ // Add subprojects block for JVM target alignment (Gradle <9 compatible)
+ // Use afterEvaluate to ensure sourceCompatibility is set before reading it,
+ // since pluginManager.withPlugin fires at plugin-apply time (before the android block)
+ rootBuildGradle += `
+subprojects {
+ pluginManager.withPlugin('org.jetbrains.kotlin.android') {
+ afterEvaluate {
+ def javaVersion = android.compileOptions.sourceCompatibility.toString()
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ jvmTarget = javaVersion
+ }
+ }
+ }
+ }
+}
+`
+ }
+ // For RN 0.84+ (Kotlin 2.x + AGP 9): No subprojects block needed.
+ // Kotlin 2.x automatically aligns JVM targets with the Android plugin.
+ // However, third-party libraries that apply kotlin-android plugin directly
+ // will fail with "sourceCompatibility is not yet finalized". Patch them
+ // to wrap the kotlin plugin in a try/catch and use compileSdkVersion method.
+ if (isKotlin2) {
+ const nodeModulesDir = `${fixtureDir}/node_modules`
+ if (fs.existsSync(nodeModulesDir)) {
+ const patchLibraryBuildGradle = (buildGradlePath) => {
+ if (!fs.existsSync(buildGradlePath)) return
+ let content = fs.readFileSync(buildGradlePath, 'utf8')
+
+ // Remove buildscript block — AGP 9 uses plugins from the root project
+ // and library buildscript blocks with old AGP versions cause conflicts
+ content = content.replace(
+ /buildscript\s*\{[\s\S]*?\n\}\s*\n/,
+ ''
+ )
+
+ // Wrap kotlin-android plugin application in try/catch (handles both
+ // single and double quotes, and both 'kotlin-android' and full plugin id)
+ content = content.replace(
+ /apply plugin:\s*["'](?:org\.jetbrains\.kotlin\.android|kotlin-android)["']/g,
+ "try { apply plugin: 'org.jetbrains.kotlin.android' } catch (e) { /* Kotlin 2.x + AGP 9 compatibility */ }"
+ )
+
+ // Update Java compatibility to VERSION_17 for Kotlin 2.x
+ content = content.replace(
+ /JavaVersion\.VERSION_1_8/g,
+ 'JavaVersion.VERSION_17'
+ )
+
+ fs.writeFileSync(buildGradlePath, content)
+ console.log(`Patched library build.gradle: ${buildGradlePath}`)
+ }
+
+ // Patch known third-party libraries that apply kotlin-android plugin directly
+ const libsToPatch = ['react-native-file-access', 'react-native-safe-area-context']
+ libsToPatch.forEach(lib => {
+ patchLibraryBuildGradle(`${nodeModulesDir}/${lib}/android/build.gradle`)
+ })
+ }
+ }
+
+ fs.writeFileSync(rootBuildGradlePath, rootBuildGradle)
+
if (newArchEnabled) {
execFileSync('./gradlew', ['generateCodegenArtifactsFromSchema'], { cwd: `${fixtureDir}/android`, stdio: 'inherit' })
}
diff --git a/test/react-native/features/fixtures/scenario-launcher/android/build.gradle b/test/react-native/features/fixtures/scenario-launcher/android/build.gradle
index 801debbf0f..591285470e 100644
--- a/test/react-native/features/fixtures/scenario-launcher/android/build.gradle
+++ b/test/react-native/features/fixtures/scenario-launcher/android/build.gradle
@@ -6,18 +6,12 @@ def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
-buildscript {
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
- }
-}
-
apply plugin: 'com.android.library'
-apply plugin: 'org.jetbrains.kotlin.android'
+try {
+ apply plugin: 'org.jetbrains.kotlin.android'
+} catch (e) {
+ // Kotlin 2.x + AGP 9 may fail here; the root project handles Kotlin config
+}
if (isNewArchitectureEnabled()) {
apply plugin: 'com.facebook.react'
}
@@ -26,16 +20,18 @@ if (isNewArchitectureEnabled()) {
android {
buildToolsVersion safeExtGet('buildToolsVersion', '28.0.3')
compileSdkVersion safeExtGet('compileSdkVersion', 28)
-
- if (android.hasProperty('namespace')) {
- namespace 'com.reactnative.scenarios'
- }
+ namespace 'com.reactnative.scenarios'
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 16)
targetSdkVersion safeExtGet('targetSdkVersion', 28)
}
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
sourceSets {
main {
if (isNewArchitectureEnabled()) {
diff --git a/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationPersistenceScenario.js b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationPersistenceScenario.js
new file mode 100644
index 0000000000..665ad59730
--- /dev/null
+++ b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationPersistenceScenario.js
@@ -0,0 +1,70 @@
+import Scenario from '../core/Scenario'
+import Bugsnag from '@bugsnag/react-native'
+import BugsnagReactNativeNavigation from '@bugsnag/plugin-react-native-navigation'
+import React, { useEffect } from 'react'
+import { Text, View } from 'react-native'
+import { Navigation } from 'react-native-navigation'
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
+
+const HomeScreen = (props) => {
+ useEffect(() => {
+ (async () => {
+ await delay(1000)
+ Bugsnag.notify(new Error('FirstNavigationError'))
+ await delay(250)
+ Navigation.push(props.componentId, {
+ component: {
+ name: 'Settings'
+ }
+ })
+ })()
+ }, [])
+
+ return (
+
+ Home Screen
+
+ )
+}
+
+const SettingsScreen = () => {
+ useEffect(() => {
+ (async () => {
+ await delay(1000)
+ throw new Error('SecondNavigationError')
+ })()
+ }, [])
+
+ return (
+
+ Settings Screen
+
+ )
+}
+
+export class ReactNativeNavigationPersistenceScenario extends Scenario {
+ constructor (configuration, jsConfig) {
+ super()
+ jsConfig.plugins = [new BugsnagReactNativeNavigation(Navigation)]
+ }
+
+ run () {
+ Navigation.registerComponent('Home', () => HomeScreen)
+ Navigation.registerComponent('Settings', () => SettingsScreen)
+
+ Navigation.setRoot({
+ root: {
+ stack: {
+ children: [
+ {
+ component: {
+ name: 'Home'
+ }
+ }
+ ]
+ }
+ }
+ })
+ }
+}
diff --git a/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationRapidNavigationScenario.js b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationRapidNavigationScenario.js
new file mode 100644
index 0000000000..d417de0cff
--- /dev/null
+++ b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/ReactNativeNavigationRapidNavigationScenario.js
@@ -0,0 +1,108 @@
+import Scenario from '../core/Scenario'
+import Bugsnag from '@bugsnag/react-native'
+import BugsnagReactNativeNavigation from '@bugsnag/plugin-react-native-navigation'
+import React, { useEffect } from 'react'
+import { Text, View } from 'react-native'
+import { Navigation } from 'react-native-navigation'
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
+
+const ScreenA = (props) => {
+ useEffect(() => {
+ (async () => {
+ await delay(200)
+ Navigation.push(props.componentId, {
+ component: {
+ name: 'ScreenB'
+ }
+ })
+ })()
+ }, [])
+
+ return (
+
+ Screen A
+
+ )
+}
+
+const ScreenB = (props) => {
+ useEffect(() => {
+ (async () => {
+ await delay(200)
+ Navigation.push(props.componentId, {
+ component: {
+ name: 'ScreenC'
+ }
+ })
+ })()
+ }, [])
+
+ return (
+
+ Screen B
+
+ )
+}
+
+const ScreenC = (props) => {
+ useEffect(() => {
+ (async () => {
+ await delay(200)
+ Navigation.push(props.componentId, {
+ component: {
+ name: 'FinalScreen'
+ }
+ })
+ })()
+ }, [])
+
+ return (
+
+ Screen C
+
+ )
+}
+
+const FinalScreen = () => {
+ useEffect(() => {
+ (async () => {
+ await delay(500)
+ Bugsnag.notify(new Error('RapidNavigationError'))
+ })()
+ }, [])
+
+ return (
+
+ Final Screen
+
+ )
+}
+
+export class ReactNativeNavigationRapidNavigationScenario extends Scenario {
+ constructor (configuration, jsConfig) {
+ super()
+ jsConfig.plugins = [new BugsnagReactNativeNavigation(Navigation)]
+ }
+
+ run () {
+ Navigation.registerComponent('ScreenA', () => ScreenA)
+ Navigation.registerComponent('ScreenB', () => ScreenB)
+ Navigation.registerComponent('ScreenC', () => ScreenC)
+ Navigation.registerComponent('FinalScreen', () => FinalScreen)
+
+ Navigation.setRoot({
+ root: {
+ stack: {
+ children: [
+ {
+ component: {
+ name: 'ScreenA'
+ }
+ }
+ ]
+ }
+ }
+ })
+ }
+}
diff --git a/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/index.js b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/index.js
index 81df59406f..1486c19f8c 100644
--- a/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/index.js
+++ b/test/react-native/features/fixtures/scenario-launcher/scenarios/react-native-navigation/index.js
@@ -1,3 +1,5 @@
// react-native-navigation.feature
export { ReactNativeNavigationBreadcrumbsEnabledScenario } from './ReactNativeNavigationBreadcrumbsEnabledScenario'
export { ReactNativeNavigationBreadcrumbsDisabledScenario } from './ReactNativeNavigationBreadcrumbsDisabledScenario'
+export { ReactNativeNavigationRapidNavigationScenario } from './ReactNativeNavigationRapidNavigationScenario'
+export { ReactNativeNavigationPersistenceScenario } from './ReactNativeNavigationPersistenceScenario'
diff --git a/test/react-native/features/react-native-navigation.feature b/test/react-native/features/react-native-navigation.feature
index de1c3bc994..396c19c82d 100644
--- a/test/react-native/features/react-native-navigation.feature
+++ b/test/react-native/features/react-native-navigation.feature
@@ -50,3 +50,25 @@ Scenario: Navigating when navigation breadcrumbs are disabled only updates conte
And the event "unhandled" is true
And the event "context" equals "Details"
And the event does not have a "navigation" breadcrumb
+
+Scenario: Multiple rapid navigation events are tracked correctly
+ When I run "ReactNativeNavigationRapidNavigationScenario"
+ And I wait to receive 1 error
+ Then the exception "message" equals "RapidNavigationError"
+ And the event "context" equals "FinalScreen"
+ And the event has a "navigation" breadcrumb named "React Native Navigation componentDidAppear"
+
+Scenario: Navigation context persists across error handling
+ When I run "ReactNativeNavigationPersistenceScenario"
+ And I relaunch the app after a crash
+ And I configure Bugsnag for "ReactNativeNavigationPersistenceScenario"
+ And I wait to receive 2 errors
+
+ # First error should have Home context
+ Then the exception "message" equals "FirstNavigationError"
+ And the event "context" equals "Home"
+ And I discard the oldest error
+
+ # Second error should have updated context
+ Then the exception "message" equals "SecondNavigationError"
+ And the event "context" equals "Settings"