From ff9001b43713b4d32b49ce2a85498bb18d053593 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 18 Jun 2026 11:18:27 +0200 Subject: [PATCH 1/4] feat(core): Use react-native-quick-base64 for envelope encoding when available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `react-native-quick-base64` as an optional peer dependency. When the package is installed by the consumer, envelope payloads are base64-encoded via its native JSI implementation (~10x faster than the bundled JS encoder). When it is absent, the SDK transparently falls back to the existing `base64-js`-derived encoder in `vendor/base64-js` — no behavior change for users who do not opt in. The encoder is resolved once at first use and cached, so the optional `require` runs at most once per session. Base64 encoding is on the hot path of `RNSentry.captureEnvelope` and is most impactful for large envelopes (profiles, attachments, replays). Closes #4884. --- CHANGELOG.md | 1 + packages/core/package.json | 6 ++- packages/core/src/js/utils/base64.ts | 56 +++++++++++++++++++++++++ packages/core/src/js/wrapper.ts | 4 +- packages/core/test/utils/base64.test.ts | 43 +++++++++++++++++++ yarn.lock | 3 ++ 6 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/js/utils/base64.ts create mode 100644 packages/core/test/utils/base64.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7af92eed0..bfcb15b496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) +- Use `react-native-quick-base64` for envelope encoding when installed, providing a ~10x speedup over the bundled JS encoder. Install it alongside `@sentry/react-native` to opt in; the SDK falls back to the JS encoder if the package is absent ([#4884](https://github.com/getsentry/sentry-react-native/issues/4884)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Fixes 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..e4c55456bf --- /dev/null +++ b/packages/core/src/js/utils/base64.ts @@ -0,0 +1,56 @@ +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 + * (~10x faster than the pure-JS fallback). 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') { + 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..9305f546aa --- /dev/null +++ b/packages/core/test/utils/base64.test.ts @@ -0,0 +1,43 @@ +import { _resetBase64EncoderForTesting, encodeToBase64 } from '../../src/js/utils/base64'; + +jest.mock( + 'react-native-quick-base64', + () => ({ + __esModule: true, + fromByteArray: jest.fn((bytes: Uint8Array) => `quick:${bytes.length}`), + }), + { virtual: true }, +); + +describe('encodeToBase64', () => { + beforeEach(() => { + _resetBase64EncoderForTesting(); + jest.resetModules(); + }); + + test('uses react-native-quick-base64 when available', () => { + // The module mock above provides a fake `fromByteArray` returning a sentinel. + const result = encodeToBase64(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79])); + expect(result).toBe('quick:6'); + }); + + 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'); + }); + }); +}); 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 From 58a6dd9ae1cd243e8b9c292215f8a671ceef5951 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 18 Jun 2026 11:22:11 +0200 Subject: [PATCH 2/4] docs(changelog): Match house style for base64 entry Link to the PR (#6314) instead of the issue, drop the unverified "~10x" figure (we have not benchmarked locally), and match the terser phrasing used by the closest analogue (#4874, the TextEncoder envelope entry). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcb15b496..c856a3d6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) -- Use `react-native-quick-base64` for envelope encoding when installed, providing a ~10x speedup over the bundled JS encoder. Install it alongside `@sentry/react-native` to opt in; the SDK falls back to the JS encoder if the package is absent ([#4884](https://github.com/getsentry/sentry-react-native/issues/4884)) +- 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)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Fixes From c97c4ddc02fc4e6a90a9709908f309dacd6a584c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 10:22:22 +0200 Subject: [PATCH 3/4] docs(changelog): Move base64 entry to Unreleased section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging main, the base64 entry ended up under the released `## 8.15.0` section. Move it back under `## Unreleased` → `### Features` as flagged by DangerJS on #6314. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c856a3d6e8..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)) @@ -30,7 +34,6 @@ - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) -- 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)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Fixes From 503dd56a9bab086ddc35974a06e378a9a21b6282 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 11:33:08 +0200 Subject: [PATCH 4/4] fix(core): Address review feedback on quick-base64 encoder - Probe the native `fromByteArray` binding once at resolution so a broken native module (e.g. autolinking failed) falls through to the JS encoder instead of throwing on every `captureEnvelope`. - Drop the unverified "~10x faster" claim from the JSDoc. - Replace the sentinel-return test mock with a real base64 implementation and assert the actual encoded string, so the test verifies the encoding output and not just the routing. - Add a test covering the broken-native-module fallback path. --- packages/core/src/js/utils/base64.ts | 8 ++++--- packages/core/test/utils/base64.test.ts | 32 ++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/utils/base64.ts b/packages/core/src/js/utils/base64.ts index e4c55456bf..18d5857cd9 100644 --- a/packages/core/src/js/utils/base64.ts +++ b/packages/core/src/js/utils/base64.ts @@ -9,9 +9,8 @@ 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 - * (~10x faster than the pure-JS fallback). Otherwise the bundled JS encoder - * from `vendor/base64-js` is used. + * `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. */ @@ -26,6 +25,9 @@ function resolveEncoder(): FromByteArray { // 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; diff --git a/packages/core/test/utils/base64.test.ts b/packages/core/test/utils/base64.test.ts index 9305f546aa..d294eeab43 100644 --- a/packages/core/test/utils/base64.test.ts +++ b/packages/core/test/utils/base64.test.ts @@ -1,10 +1,12 @@ 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: jest.fn((bytes: Uint8Array) => `quick:${bytes.length}`), + fromByteArray: quickFromByteArray, }), { virtual: true }, ); @@ -12,13 +14,17 @@ jest.mock( describe('encodeToBase64', () => { beforeEach(() => { _resetBase64EncoderForTesting(); + quickFromByteArray.mockClear(); jest.resetModules(); }); test('uses react-native-quick-base64 when available', () => { - // The module mock above provides a fake `fromByteArray` returning a sentinel. + // "sentry" => "c2VudHJ5" const result = encodeToBase64(new Uint8Array([0x73, 0x65, 0x6e, 0x74, 0x72, 0x79])); - expect(result).toBe('quick:6'); + 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', () => { @@ -40,4 +46,24 @@ describe('encodeToBase64', () => { 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); + }); + }); });