diff --git a/CHANGELOG.md b/CHANGELOG.md index a7af92eed0..7109514fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Use the optional `react-native-quick-base64` peer dependency for envelope base64 encoding when installed, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if the package is absent. ([#6314](https://github.com/getsentry/sentry-react-native/pull/6314)) + ### Fixes - Remove unused `React/RCTTextView.h` import that broke iOS builds on React Native 0.87, where the header was removed as part of the legacy architecture cleanup ([#6322](https://github.com/getsentry/sentry-react-native/pull/6322)) diff --git a/packages/core/package.json b/packages/core/package.json index 793286d98e..c043a40b0c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,7 +70,8 @@ "peerDependencies": { "expo": ">=49.0.0", "react": ">=17.0.0", - "react-native": ">=0.65.0" + "react-native": ">=0.65.0", + "react-native-quick-base64": ">=3.0.0" }, "dependencies": { "@sentry/babel-plugin-component-annotate": "5.3.0", @@ -130,6 +131,9 @@ "peerDependenciesMeta": { "expo": { "optional": true + }, + "react-native-quick-base64": { + "optional": true } } } diff --git a/packages/core/src/js/utils/base64.ts b/packages/core/src/js/utils/base64.ts new file mode 100644 index 0000000000..18d5857cd9 --- /dev/null +++ b/packages/core/src/js/utils/base64.ts @@ -0,0 +1,58 @@ +import { debug } from '@sentry/core'; + +import { base64StringFromByteArray } from '../vendor'; + +type FromByteArray = (bytes: Uint8Array, urlSafe?: boolean) => string; + +let cachedEncoder: FromByteArray | null = null; +let resolved = false; + +/** + * Resolves the base64 encoder once. If the optional peer dependency + * `react-native-quick-base64` is installed, its native JSI encoder is used. + * Otherwise the bundled JS encoder from `vendor/base64-js` is used. + * + * The resolution is cached so the require cost is paid at most once. + */ +function resolveEncoder(): FromByteArray { + if (resolved) { + return cachedEncoder ?? base64StringFromByteArray; + } + resolved = true; + + try { + // Optional peer dependency — only loaded if the consumer installed it. + // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies + const quickBase64 = require('react-native-quick-base64') as { fromByteArray?: FromByteArray }; + if (quickBase64 && typeof quickBase64.fromByteArray === 'function') { + // Probe the native binding so that a broken native module falls through + // to the JS encoder instead of throwing on every envelope. + quickBase64.fromByteArray(new Uint8Array([0])); + cachedEncoder = quickBase64.fromByteArray; + debug.log('Using react-native-quick-base64 for envelope encoding.'); + return cachedEncoder; + } + } catch (_e) { + // Not installed — fall through to JS encoder. + } + + cachedEncoder = base64StringFromByteArray; + return cachedEncoder; +} + +/** + * Encode a byte array to a base64 string. Prefers the native + * `react-native-quick-base64` encoder when available, otherwise uses the + * bundled JS implementation. + */ +export function encodeToBase64(input: Uint8Array): string { + return resolveEncoder()(input); +} + +/** + * @internal Test helper. Resets the cached encoder so the next call re-resolves. + */ +export function _resetBase64EncoderForTesting(): void { + cachedEncoder = null; + resolved = false; +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index d4225740d6..f53cfa5726 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -30,11 +30,11 @@ import type { MobileReplayOptions } from './replay/mobilereplay'; import type { RequiredKeysUser } from './user'; import { isHardCrash } from './misc'; +import { encodeToBase64 } from './utils/base64'; import { encodeUTF8 } from './utils/encode'; import { isTurboModuleEnabled } from './utils/environment'; import { convertToNormalizedObject } from './utils/normalize'; import { ReactNativeLibraries } from './utils/rnlibraries'; -import { base64StringFromByteArray } from './vendor'; import { SDK_VERSION } from './version'; /** @@ -231,7 +231,7 @@ export const NATIVE: SentryNativeWrapper = { envelopeBytes = newBytes; } - await RNSentry.captureEnvelope(base64StringFromByteArray(envelopeBytes), { hardCrashed }); + await RNSentry.captureEnvelope(encodeToBase64(envelopeBytes), { hardCrashed }); }, /** diff --git a/packages/core/test/utils/base64.test.ts b/packages/core/test/utils/base64.test.ts new file mode 100644 index 0000000000..d294eeab43 --- /dev/null +++ b/packages/core/test/utils/base64.test.ts @@ -0,0 +1,69 @@ +import { _resetBase64EncoderForTesting, encodeToBase64 } from '../../src/js/utils/base64'; + +const quickFromByteArray = jest.fn((bytes: Uint8Array) => Buffer.from(bytes).toString('base64')); + +jest.mock( + 'react-native-quick-base64', + () => ({ + __esModule: true, + fromByteArray: quickFromByteArray, + }), + { virtual: true }, +); + +describe('encodeToBase64', () => { + beforeEach(() => { + _resetBase64EncoderForTesting(); + quickFromByteArray.mockClear(); + jest.resetModules(); + }); + + test('uses react-native-quick-base64 when available', () => { + // "sentry" => "c2VudHJ5" + const result = encodeToBase64(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79])); + expect(result).toBe('c2VudHJ5'); + // Probe call during resolution + the actual encode call. + expect(quickFromByteArray).toHaveBeenCalledTimes(2); + expect(quickFromByteArray).toHaveBeenLastCalledWith(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79])); + }); + + test('falls back to the JS encoder when react-native-quick-base64 is not installed', () => { + jest.isolateModules(() => { + jest.doMock( + 'react-native-quick-base64', + () => { + throw new Error('Cannot find module'); + }, + { virtual: true }, + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { + encodeToBase64: isolatedEncode, + _resetBase64EncoderForTesting: isolatedReset, + } = require('../../src/js/utils/base64'); + isolatedReset(); + // "sentry" => "c2VudHJ5" + expect(isolatedEncode(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79]))).toBe('c2VudHJ5'); + }); + }); + + test('falls back to the JS encoder when the native binding throws on probe', () => { + jest.isolateModules(() => { + const brokenFromByteArray = jest.fn(() => { + throw new Error('native module not linked'); + }); + jest.doMock('react-native-quick-base64', () => ({ __esModule: true, fromByteArray: brokenFromByteArray }), { + virtual: true, + }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { + encodeToBase64: isolatedEncode, + _resetBase64EncoderForTesting: isolatedReset, + } = require('../../src/js/utils/base64'); + isolatedReset(); + expect(isolatedEncode(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79]))).toBe('c2VudHJ5'); + // Probe was attempted exactly once; the broken binding is not used for the real encode. + expect(brokenFromByteArray).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 34ecb78c65..0c272a0a0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10931,9 +10931,12 @@ __metadata: expo: ">=49.0.0" react: ">=17.0.0" react-native: ">=0.65.0" + react-native-quick-base64: ">=3.0.0" peerDependenciesMeta: expo: optional: true + react-native-quick-base64: + optional: true bin: sentry-eas-build-on-complete: scripts/eas-build-hook.js sentry-eas-build-on-error: scripts/eas-build-hook.js