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"