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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# Changelog

## [Unreleased]

### Changed

- (react-native) Introduced a lazy-loading singleton client to ensure a single client instance per app [#748](https://github.com/bugsnag/bugsnag-js-performance/pull/748)

## [v3.2.0] (2025-11-13)

### Added

- (react-native): Added `createReactNativeClient` factory method for internal use by upstream libraries [#730](https://github.com/bugsnag/bugsnag-js-performance/pull/730)
- (react-native) Added `createReactNativeClient` factory method for internal use by upstream libraries [#730](https://github.com/bugsnag/bugsnag-js-performance/pull/730)

### Changed

Expand Down
47 changes: 45 additions & 2 deletions packages/platforms/react-native/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import createClock from './clock'
import type { ReactNativeConfiguration } from './config'
import { createReactNativeClient } from './create-client'
import type { PlatformExtensions } from './platform-extensions'
import type { BugsnagPerformance } from '@bugsnag/core-performance'

const BugsnagPerformance = createReactNativeClient()
// Singleton client instance shared across the entire application.
// Will be lazily created on first property access to the BugsnagPerformance proxy below,
// OR can be pre-configured by calling registerClient()
let clientInstance: unknown

export default BugsnagPerformance
// Sets the singleton BugsnagPerformance client instance.
// Upstream libraries such as Expo MUST call this to pre-configure the client instance upfront before any usage.
export function registerClient<C extends ReactNativeConfiguration = ReactNativeConfiguration, T = PlatformExtensions> (client: BugsnagPerformance<C, T>): void {
clientInstance = client
}

const clock = createClock(performance)
const appStartTime = clock.now()

// Proxy wrapper for lazy initialization of the BugsnagPerformance client.
// On first property access:
// - Uses pre-configured client from registerClient() if available, otherwise creates a new client
// - Copies all client properties to the proxy target and removes the get trap
// - All subsequent accesses go directly to the target, bypassing the proxy for optimal performance
const client = new Proxy({} as BugsnagPerformance<ReactNativeConfiguration, PlatformExtensions>, {
get (target, prop) {
// React uses the $$typeof property to identify React elements - we return undefined
// to avoid triggering client creation during React's internal type checking
if (prop === '$$typeof') return undefined

// initialize the client instance here if it hasn't already been set via registerClient()
if (!clientInstance) {
clientInstance = createReactNativeClient({ appStartTime, clock })
}

// Now that the client has been initialized, we can add all its properties to the proxy target
// and remove the get trap in order to bypass the proxy for future accesses
const client = clientInstance as BugsnagPerformance<ReactNativeConfiguration, PlatformExtensions>
Object.defineProperties(target, Object.getOwnPropertyDescriptors(client))
delete this.get

return client[prop as keyof typeof client]
}
})

export default client
3 changes: 2 additions & 1 deletion packages/platforms/react-native/lib/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ReactNativeSpanFactory } from './span-factory'

// Options type for createReactNativeClient allowing additional plugins and customized platform extensions
export interface ReactNativeClientOptions<S extends ReactNativeSchema, C extends ReactNativeConfiguration, T> extends Partial<Omit<ClientOptions<S, C, T>, 'platformExtensions'>> {
appStartTime?: number
createPlatformExtensions?: (appStartTime: number, clock: Clock, spanFactory: ReactNativeSpanFactory<C>, spanContextStorage: SpanContextStorage) => T
}

Expand All @@ -35,7 +36,7 @@ export function createReactNativeClient<S extends ReactNativeSchema = ReactNativ
const isDebuggingRemotely = !global.nativeCallSyncHook && !global.RN$Bridgeless

const clock = options?.clock || createClock(performance)
const appStartTime = clock.now()
const appStartTime = options?.appStartTime || clock.now()
const isDevelopment = options?.isDevelopment || __DEV__
const schema = options?.schema || createSchema(isDevelopment) as S

Expand Down
1 change: 1 addition & 0 deletions packages/platforms/react-native/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export { ReactNativeSpanFactory } from './span-factory'
export { createSchema as createReactNativeSchema } from './config'
export { createReactNativeClient } from './create-client'
export type { ReactNativeClientOptions } from './create-client'
export { registerClient } from './client'

export default BugsnagPerformance
49 changes: 49 additions & 0 deletions packages/platforms/react-native/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { ReactNativeConfiguration } from '../lib/config'
import type { BugsnagPerformance } from '@bugsnag/core-performance'
import type { PlatformExtensions } from '../lib/platform-extensions'
import { IncrementingClock } from '@bugsnag/js-performance-test-utilities'

let client: BugsnagPerformance<ReactNativeConfiguration, PlatformExtensions>

Expand All @@ -20,3 +21,51 @@ describe('Default React Native Client', () => {
expect(client.attach).toBeDefined()
})
})

describe('singleton behavior', () => {
beforeEach(() => {
// Reset modules to clear the singleton
jest.resetModules()
})

it('should allow custom clients to be set and used as default', () => {
const { registerClient } = require('../lib/client')
const { createReactNativeClient } = require('../lib/create-client')
const clock = new IncrementingClock()

// Import the default client
const defaultClient = require('../lib/client').default

// Create and register a custom client
const testClient = createReactNativeClient({ clock })
registerClient(testClient)

// The default should proxy to the test client
expect(defaultClient.start).toBe(testClient.start)
expect(defaultClient.startSpan).toBe(testClient.startSpan)
})

it('should create default client if none is set', () => {
const createReactNativeClient = jest.spyOn(require('../lib/create-client'), 'createReactNativeClient')
const client = require('../lib/client')
const defaultClient = client.default

// createReactNativeClient should be called on first access
expect(createReactNativeClient).not.toHaveBeenCalled()
expect(defaultClient.start).toBeDefined()
expect(defaultClient.startSpan).toBeDefined()
expect(createReactNativeClient).toHaveBeenCalledTimes(1)
})

it('should correctly handle getter properties after trap removal', () => {
const defaultClient = require('../lib/client').default

// starting a span will trigger trap removal
const span = defaultClient.startSpan('test-span')

// Getters such as currentSpanContext should return the current value
// as opposed to the value at the time of first access
expect(defaultClient.currentSpanContext).toBeDefined()
expect(defaultClient.currentSpanContext.id).toBe(span.id)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ describe('Platform Extensions', () => {

it('starts the client using the native configuration', () => {
const nativeConfig = turboModule.attachToNativeSDK()
client = require('../lib/client').default
const { createReactNativeClient } = require('../lib/create-client')
client = createReactNativeClient()
const startSpy = jest.spyOn(client, 'start')

client.attach({
Expand Down Expand Up @@ -114,7 +115,8 @@ describe('Platform Extensions', () => {

it('does not overwrite native configuration with JS values', () => {
const nativeConfig = turboModule.attachToNativeSDK()
client = require('../lib/client').default
const { createReactNativeClient } = require('../lib/create-client')
client = createReactNativeClient()
const startSpy = jest.spyOn(client, 'start')

client.attach({
Expand Down