From 72fe3dccd28de4565489842cbdcc1b8fb30e026f Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 25 Sep 2026 18:42:53 -0400 Subject: [PATCH 1/3] react-native: remove circular imports that break under Metro's import/export transform With breadcrumbs enabled the client crashed with "Cannot read property 'prototype' of undefined" in apps whose Metro config enables experimentalImportSupport without inline requires: FileBreadcrumbsStorage imported BacktraceFileAttachment through the package barrel and the builder imported the client while the client imported the builder, and Metro's static import transform captures those bindings while the barrel is still evaluating. The storage now imports the attachment directly, the builder is defined next to the client, the exception handlers import the client as a type only, and import/no-cycle guards the package. --- packages/react-native/.eslintrc.json | 14 ++++ packages/react-native/src/BacktraceClient.ts | 69 +++++++++++++++- .../src/breadcrumbs/FileBreadcrumbsStorage.ts | 2 +- .../src/builder/BacktraceClientBuilder.ts | 66 +--------------- .../src/handlers/ExceptionHandler.ts | 2 +- .../src/handlers/UnhandledExceptionHandler.ts | 2 +- .../AndroidUnhandledExceptionHandler.ts | 2 +- .../tests/clientInitializationTests.spec.ts | 79 +++++++++++++++++++ 8 files changed, 167 insertions(+), 69 deletions(-) create mode 100644 packages/react-native/.eslintrc.json create mode 100644 packages/react-native/tests/clientInitializationTests.spec.ts diff --git a/packages/react-native/.eslintrc.json b/packages/react-native/.eslintrc.json new file mode 100644 index 000000000..a8e317b2c --- /dev/null +++ b/packages/react-native/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "plugins": ["import"], + "settings": { + "import/ignore": ["node_modules"], + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + }, + "rules": { + "import/no-cycle": ["error", { "ignoreExternal": true }] + } +} diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index f3dc5a664..a12036909 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -1,5 +1,6 @@ import { BacktraceCoreClient, + BacktraceCoreClientBuilder, BreadcrumbsManager, SingleSessionProvider, SubmissionUrlInformation, @@ -14,10 +15,15 @@ import { NativeModules, Platform } from 'react-native'; import { AnrException } from './anr/AnrException'; import { AnrReporter } from './anr/AnrReporter'; import { AnrWatchdogHandler } from './anr/AnrWatchdogHandler'; +import { NativeAttributeProvider } from './attributes/NativeAttributeProvider'; +import { ReactNativeAttributeProvider } from './attributes/ReactNativeAttributeProvider'; import { BacktraceAnrType, type BacktraceConfiguration } from './BacktraceConfiguration'; +import { AppStateBreadcrumbSubscriber } from './breadcrumbs/events/AppStateBreadcrumbSubscriber'; +import { DimensionChangeBreadcrumbSubscriber } from './breadcrumbs/events/DimensionChangeBreadcrumbSubscriber'; +import { WebRequestEventSubscriber } from './breadcrumbs/events/WebRequestEventSubscriber'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage'; -import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; import type { BacktraceClientSetup } from './builder/BacktraceClientSetup'; +import { DebuggerHelper } from './common/DebuggerHelper'; import { version } from './common/platformHelper'; import { version as agentVersion } from '../package.json'; import { CrashReporter } from './crashReporter/CrashReporter'; @@ -27,6 +33,7 @@ import { type ExceptionHandler } from './handlers/ExceptionHandler'; import { ReactNativeRequestHandler } from './ReactNativeRequestHandler'; import { ReactStackTraceConverter } from './ReactStackTraceConverter'; import { type FileSystem } from './storage/FileSystem'; +import { ReactNativeFileSystem } from './storage/ReactNativeFileSystem'; // Must match the private attribute name BreadcrumbsManager sets on JS reports. const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId'; @@ -229,3 +236,63 @@ export class BacktraceClient extends BacktraceCoreClient return crashReporter; } } + +/** + * Builder for {@link BacktraceClient}; obtain one with `BacktraceClient.builder(options)`. + */ +// Implementation note: +// defined in the same module as `BacktraceClient` on purpose. `BacktraceClient.builder()` creates the builder and `BacktraceClientBuilder.build()` creates the client, splitting them into two modules creates a circular import. +// The browser and node packages keep that cycle harmlessly because rollup bundles them into a single scope; +// this package ships per-file modules to Metro, and Metro's import/export transform (`experimentalImportSupport`) captures imported bindings when a module is evaluated, which leaves one side of the cycle `undefined`. +export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { + constructor(clientSetup: BacktraceClientSetup) { + super(clientSetup); + + this.addAttributeProvider(new ReactNativeAttributeProvider()); + if (!DebuggerHelper.isNativeBridgeEnabled()) { + return; + } + + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + return; + } + + const attributeProviders = Platform.select({ + ios: [ + new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceMemoryUsageAttributeProvider', 'dynamic'), + new NativeAttributeProvider('BacktraceCpuAttributeProvider', 'dynamic'), + ], + android: [ + new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), + new NativeAttributeProvider('MemoryInformationAttributeProvider', 'dynamic'), + new NativeAttributeProvider('ProcessAttributeProvider', 'dynamic'), + ], + default: [], + }); + + for (const provider of attributeProviders) { + this.addAttributeProvider(provider); + } + + this.useFileSystem(new ReactNativeFileSystem()); + this.useBreadcrumbSubscriber(new AppStateBreadcrumbSubscriber()); + this.useBreadcrumbSubscriber(new DimensionChangeBreadcrumbSubscriber()); + this.useBreadcrumbSubscriber(new WebRequestEventSubscriber()); + } + + public useFileSystem(fileSystem: ReactNativeFileSystem): this { + super.useFileSystem(fileSystem); + return this; + } + + public build(): BacktraceClient { + const instance = new BacktraceClient(this.clientSetup); + instance.initialize(); + return instance; + } +} diff --git a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts index 2b0943570..a19a67d2e 100644 --- a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -12,7 +12,7 @@ import { type RawBreadcrumb, } from '@backtrace/sdk-core'; import { WritableStream } from 'web-streams-polyfill'; -import { BacktraceFileAttachment } from '..'; +import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment'; import { type FileSystem } from '../storage'; import { ChunkifierSink, type ChunkSplitterFactory } from '../storage/Chunkifier'; import { combinedChunkSplitter } from '../storage/combinedChunkSplitter'; diff --git a/packages/react-native/src/builder/BacktraceClientBuilder.ts b/packages/react-native/src/builder/BacktraceClientBuilder.ts index 882dc2bbd..0ec98e5c1 100644 --- a/packages/react-native/src/builder/BacktraceClientBuilder.ts +++ b/packages/react-native/src/builder/BacktraceClientBuilder.ts @@ -1,64 +1,2 @@ -import { BacktraceCoreClientBuilder } from '@backtrace/sdk-core'; -import { Platform } from 'react-native'; -import { NativeAttributeProvider } from '../attributes/NativeAttributeProvider'; -import { ReactNativeAttributeProvider } from '../attributes/ReactNativeAttributeProvider'; -import { BacktraceClient } from '../BacktraceClient'; -import { AppStateBreadcrumbSubscriber } from '../breadcrumbs/events/AppStateBreadcrumbSubscriber'; -import { DimensionChangeBreadcrumbSubscriber } from '../breadcrumbs/events/DimensionChangeBreadcrumbSubscriber'; -import { WebRequestEventSubscriber } from '../breadcrumbs/events/WebRequestEventSubscriber'; -import { DebuggerHelper } from '../common/DebuggerHelper'; -import { ReactNativeFileSystem } from '../storage'; -import type { BacktraceClientSetup } from './BacktraceClientSetup'; - -export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { - constructor(clientSetup: BacktraceClientSetup) { - super(clientSetup); - - this.addAttributeProvider(new ReactNativeAttributeProvider()); - if (!DebuggerHelper.isNativeBridgeEnabled()) { - return; - } - - if (Platform.OS !== 'android' && Platform.OS !== 'ios') { - return; - } - - const attributeProviders = Platform.select({ - ios: [ - new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceMemoryUsageAttributeProvider', 'dynamic'), - new NativeAttributeProvider('BacktraceCpuAttributeProvider', 'dynamic'), - ], - android: [ - new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), - new NativeAttributeProvider('MemoryInformationAttributeProvider', 'dynamic'), - new NativeAttributeProvider('ProcessAttributeProvider', 'dynamic'), - ], - default: [], - }); - - for (const provider of attributeProviders) { - this.addAttributeProvider(provider); - } - - this.useFileSystem(new ReactNativeFileSystem()); - this.useBreadcrumbSubscriber(new AppStateBreadcrumbSubscriber()); - this.useBreadcrumbSubscriber(new DimensionChangeBreadcrumbSubscriber()); - this.useBreadcrumbSubscriber(new WebRequestEventSubscriber()); - } - - public useFileSystem(fileSystem: ReactNativeFileSystem): this { - super.useFileSystem(fileSystem); - return this; - } - - public build(): BacktraceClient { - const instance = new BacktraceClient(this.clientSetup); - instance.initialize(); - return instance; - } -} +// `BacktraceClientBuilder` lives next to `BacktraceClient` (see the note there); this module keeps the import path. +export { BacktraceClientBuilder } from '../BacktraceClient'; diff --git a/packages/react-native/src/handlers/ExceptionHandler.ts b/packages/react-native/src/handlers/ExceptionHandler.ts index 6acd09097..a46ec1ab9 100644 --- a/packages/react-native/src/handlers/ExceptionHandler.ts +++ b/packages/react-native/src/handlers/ExceptionHandler.ts @@ -1,4 +1,4 @@ -import { BacktraceClient } from '../BacktraceClient'; +import type { BacktraceClient } from '../BacktraceClient'; export interface ExceptionHandler { captureUnhandledPromiseRejections(client: BacktraceClient): void; diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 93a4b7c99..673ceb003 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -1,5 +1,5 @@ import { BacktraceReport } from '@backtrace/sdk-core'; -import { BacktraceClient } from '../BacktraceClient'; +import type { BacktraceClient } from '../BacktraceClient'; import { hermes } from '../common/hermesHelper'; import { CrashReporter } from '../crashReporter/CrashReporter'; import { type ExceptionHandler } from './ExceptionHandler'; diff --git a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts index 76a6cbc8b..f2beeb4ce 100644 --- a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts @@ -1,6 +1,6 @@ import { BacktraceReport } from '@backtrace/sdk-core'; import { NativeModules } from 'react-native'; -import { BacktraceClient } from '../../BacktraceClient'; +import type { BacktraceClient } from '../../BacktraceClient'; import { DebuggerHelper } from '../../common/DebuggerHelper'; import { AndroidStackTraceConverter } from '../../converters/AndroidStackTraceConverter'; import { UnhandledExceptionHandler } from '../UnhandledExceptionHandler'; diff --git a/packages/react-native/tests/clientInitializationTests.spec.ts b/packages/react-native/tests/clientInitializationTests.spec.ts new file mode 100644 index 000000000..dfcdccbbf --- /dev/null +++ b/packages/react-native/tests/clientInitializationTests.spec.ts @@ -0,0 +1,79 @@ +import { NativeModules } from 'react-native'; +import { promisify } from 'util'; +import { mockStreamFileSystem } from './_mocks/fileSystem'; + +// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws. +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + OS: 'ios', + select: (options: Record) => (options.ios !== undefined ? options.ios : options.default), + }, +})); + +jest.mock('../src/common/platformHelper', () => ({ + version: () => '0.81.6', +})); + +// Keep the builder away from the native modules: +// with the bridge reported as unavailable it registers neither native attribute providers nor the native file system, and the test injects a mocked file system instead. +NativeModules.UIManager = {}; +NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' }; + +// Loaded through the package entry point on purpose: this is the path applications take, +// it's where the circular imports between the client, the builder and the breadcrumbs storage used to surface. +/* eslint-disable @typescript-eslint/no-var-requires */ +const { BacktraceClient, BacktraceClientBuilder, BacktraceFileAttachment } = require('../src'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const nextTick = promisify(process.nextTick); + +const options = { + url: 'https://submit.backtrace.io/universe/token/json', + database: { enable: true, path: '/backtrace' }, + metrics: { enable: false }, + breadcrumbs: { enable: true, maximumBreadcrumbs: 4 }, + captureUnhandledErrors: false, + captureUnhandledPromiseRejections: false, + userAttributes: { application: 'clientInitialization', 'application.version': '1.0.0' }, +}; + +describe('BacktraceClient initialization through the package entry point', () => { + afterEach(() => { + BacktraceClient.instance?.dispose(); + // initialize() stores the singleton on BacktraceClient itself, which dispose() does not clear. + (BacktraceClient as { _instance?: unknown })._instance = undefined; + }); + + it('Should create the builder from the client', () => { + expect(BacktraceClient.builder(options)).toBeInstanceOf(BacktraceClientBuilder); + }); + + it('Should initialize the client through the builder', () => { + const client = BacktraceClient.initialize(options, (builder: typeof BacktraceClientBuilder.prototype) => { + expect(builder).toBeInstanceOf(BacktraceClientBuilder); + builder.useFileSystem(mockStreamFileSystem()); + }); + + expect(client).toBeInstanceOf(BacktraceClient); + expect(BacktraceClient.instance).toBe(client); + }); + + it('Should expose breadcrumb files as file attachments', async () => { + const client = BacktraceClient.initialize(options, (builder: typeof BacktraceClientBuilder.prototype) => + builder.useFileSystem(mockStreamFileSystem()), + ); + + // Enough breadcrumbs to rotate the breadcrumb files at least once. + for (let i = 0; i < 20; i++) { + client.breadcrumbs?.info(`breadcrumb-${i}`); + await nextTick(); + } + for (let i = 0; i < 10; i++) { + await nextTick(); + } + + const attachments = client.attachments.filter((attachment) => attachment instanceof BacktraceFileAttachment); + expect(attachments.length).toBeGreaterThan(0); + }); +}); From d2f5515292293b50b054585fd24a23dc0ce9e8dd Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 25 Sep 2026 18:43:26 -0400 Subject: [PATCH 2/3] react-native: make Android autolinking compile under Expo and React Native 0.82+ Expo's autolinking derives the PackageList import from the Gradle namespace, which named a package that does not exist, so the generated PackageList.java failed to compile; the namespace now matches the Java package of ReactNativePackage. The New Architecture check also read newArchEnabled from the root project, which React Native 0.82+ no longer sets, so the library skipped codegen while the app's C++ autolinking still expected its output. --- packages/react-native/android/build.gradle | 11 +++++++++-- packages/react-native/android/gradle.properties | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-native/android/build.gradle b/packages/react-native/android/build.gradle index 12ddb8a05..6c4f14d93 100644 --- a/packages/react-native/android/build.gradle +++ b/packages/react-native/android/build.gradle @@ -10,7 +10,11 @@ buildscript { } def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" + // Read the property from this project rather than the root project: + // since React Native 0.82 the New Architecture is always on and the React Native Gradle plugin sets `newArchEnabled` on every subproject, while the app's gradle.properties no longer has to define it. + return ["newArchEnabled", "react.newArchEnabled"].any { name -> + project.hasProperty(name) && project.getProperty(name).toString().equalsIgnoreCase("true") + } } apply plugin: "com.android.library" @@ -38,7 +42,9 @@ def supportsNamespace() { android { if (supportsNamespace()) { - namespace "com.backtrace.reactnative" + // Must match the Java package of ReactNativePackage: Expo's autolinking derives the + // `import .ReactNativePackage;` line in PackageList.java from the namespace. + namespace "backtraceio.library" sourceSets { main { @@ -89,6 +95,7 @@ if (isNewArchitectureEnabled()) { react { jsRootDir = file("../src/") libraryName = "ReactNative" + // Java package for codegen output only; intentionally independent of the library namespace above. codegenJavaPackageName = "com.backtrace.reactnative" } } diff --git a/packages/react-native/android/gradle.properties b/packages/react-native/android/gradle.properties index ccd5c2df1..85e2941f8 100644 --- a/packages/react-native/android/gradle.properties +++ b/packages/react-native/android/gradle.properties @@ -1,3 +1,4 @@ +# Do not set newArchEnabled / react.newArchEnabled here: a project-directory gradle.properties overrides the consuming app's root gradle.properties for this subproject. ReactNative_minSdkVersion=16 ReactNative_targetSdkVersion=35 ReactNative_compileSdkVersion=35 From 1e773a4911ad62f2e3b8eeb1a48d8188e8a02098 Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 25 Sep 2026 18:43:58 -0400 Subject: [PATCH 3/3] react-native: support use_frameworks! with dynamic linkage od install rejected apps using dynamic frameworks because PLCrashReporter is a static xcframework, the pod now declares itself a static framework, which is a no-op for static and non-framework setups. --- packages/react-native/backtrace-react-native.podspec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-native/backtrace-react-native.podspec b/packages/react-native/backtrace-react-native.podspec index 8668928ed..24b56e6bf 100644 --- a/packages/react-native/backtrace-react-native.podspec +++ b/packages/react-native/backtrace-react-native.podspec @@ -15,7 +15,11 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/backtrace-labs/backtrace-javascript.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm}" - + + # PLCrashReporter is a static xcframework, this pod has to be built as a static framework + # when the app uses `use_frameworks!` with dynamic linkage (CocoaPods rejects the install otherwise). + s.static_framework = true + s.dependency "PLCrashReporter" # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.