From f43a4f573156917e702de772eb64eae005372cf0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 28 May 2026 11:16:12 +0200 Subject: [PATCH 1/9] Turbo Modules crash time context --- packages/core/etc/sentry-react-native.api.md | 58 ++++++- packages/core/src/js/index.ts | 9 ++ packages/core/src/js/integrations/default.ts | 6 + packages/core/src/js/integrations/exports.ts | 2 + .../src/js/integrations/turboModuleContext.ts | 50 ++++++ packages/core/src/js/turbomodule/index.ts | 8 + .../src/js/turbomodule/turboModuleTracker.ts | 145 ++++++++++++++++++ .../src/js/turbomodule/wrapTurboModule.ts | 119 ++++++++++++++ .../integrations/turboModuleContext.test.ts | 55 +++++++ .../turbomodule/turboModuleTracker.test.ts | 101 ++++++++++++ .../test/turbomodule/wrapTurboModule.test.ts | 129 ++++++++++++++++ 11 files changed, 675 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/js/integrations/turboModuleContext.ts create mode 100644 packages/core/src/js/turbomodule/index.ts create mode 100644 packages/core/src/js/turbomodule/turboModuleTracker.ts create mode 100644 packages/core/src/js/turbomodule/wrapTurboModule.ts create mode 100644 packages/core/test/integrations/turboModuleContext.test.ts create mode 100644 packages/core/test/turbomodule/turboModuleTracker.test.ts create mode 100644 packages/core/test/turbomodule/wrapTurboModule.test.ts diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index cb69dab67e..a0dbc51603 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -145,7 +145,7 @@ export const appRegistryIntegration: () => Integration & { // // @public export const appStartIntegration: (input?: { - standalone?: boolean; + standalone?: boolean | undefined; }) => AppStartIntegration; export { Breadcrumb } @@ -334,7 +334,7 @@ export { FeedbackForm as FeedbackWidget } export const feedbackIntegration: (initOptions?: Partial & { buttonOptions?: FeedbackButtonProps; screenshotButtonOptions?: ScreenshotButtonProps; - colorScheme?: "system" | "light" | "dark"; + colorScheme?: 'system' | 'light' | 'dark'; themeLight?: Partial; themeDark?: Partial; enableShakeToReport?: boolean; @@ -347,6 +347,9 @@ export { functionToStringIntegration } export { getActiveSpan } +// @public +export function getActiveTurboModuleCall(): TurboModuleCall | undefined; + export { getClient } // Warning: (ae-forgotten-export) The symbol "ReactNativeTracingIntegration" needs to be exported by the entry point index.d.ts @@ -371,6 +374,9 @@ export function getReactNativeTracingIntegration(client: Client): ReactNativeTra export { getRootSpan } +// @public +export function getTurboModuleCallStack(): TurboModuleCall[]; + // Warning: (ae-forgotten-export) The symbol "GlobalErrorBoundaryState" needs to be exported by the entry point index.d.ts // // @public @@ -498,11 +504,22 @@ export { OpenAiOptions } // @public export function pauseAppHangTracking(): void; +// @public +export function popTurboModuleCall(callId: number, scope?: Scope): void; + // @public export const primitiveTagIntegration: () => Integration; export { Profiler } +// @public +export function pushTurboModuleCall(args: { + name: string; + method: string; + kind: 'sync' | 'async'; + scope?: Scope; +}): number; + // Warning: (ae-forgotten-export) The symbol "ReactNativeClientOptions" needs to be exported by the entry point index.d.ts // // @public @@ -633,14 +650,15 @@ export { Stacktrace } // @public export const stallTrackingIntegration: (input?: { - minimumStallThresholdMs?: number; + minimumStallThresholdMs?: number | undefined; }) => Integration; -// Warning: (ae-forgotten-export) The symbol "defaultIdleOptions" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial & { - isAppRestart?: boolean; +export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial<{ + idleTimeout: number; + finalTimeout: number; +}> & { + isAppRestart?: boolean | undefined; }) => Span | undefined; // @public @@ -712,6 +730,27 @@ export class TouchEventBoundary extends React_2.Component Integration; + +// @public (undocumented) +export interface TurboModuleContextOptions { + modules?: Array<{ + name: string; + module: object | null | undefined; + skipMethods?: ReadonlyArray; + }>; +} + // @public (undocumented) export const Unmask: HostComponent | React_2.ComponentType; @@ -756,6 +795,11 @@ export function wrapExpoImage(imageClass: T): T; // @public export function wrapExpoRouter(router: T): T; +// @public +export function wrapTurboModule(name: string, module: T | null | undefined, options?: { + skip?: ReadonlyArray; +}): T | null | undefined; + // Warnings were encountered during analysis: // // src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 46d0d6a6f6..1218235dc5 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -155,3 +155,12 @@ export { FeedbackForm as FeedbackWidget } from './feedback/FeedbackForm'; export { showFeedbackForm as showFeedbackWidget } from './feedback/FeedbackFormManager'; export { getDataFromUri } from './wrapper'; + +export { + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, + wrapTurboModule, +} from './turbomodule'; +export type { TurboModuleCall } from './turbomodule'; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index f91ed4a89c..a7e6c5f487 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -41,6 +41,7 @@ import { spotlightIntegration, stallTrackingIntegration, timeToDisplayIntegration, + turboModuleContextIntegration, userInteractionIntegration, viewHierarchyIntegration, } from './exports'; @@ -172,5 +173,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(primitiveTagIntegration()); + if (options.enableNative) { + // Attribute native crashes to the active TurboModule method (see #6163). + integrations.push(turboModuleContextIntegration()); + } + return integrations; } diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 4319f6843e..1630272bb4 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -26,6 +26,8 @@ export { appRegistryIntegration } from './appRegistry'; export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration'; export { breadcrumbsIntegration } from './breadcrumbs'; export { primitiveTagIntegration } from './primitiveTagIntegration'; +export { turboModuleContextIntegration } from './turboModuleContext'; +export type { TurboModuleContextOptions } from './turboModuleContext'; export { logEnricherIntegration } from './logEnricherIntegration'; export { graphqlIntegration } from './graphql'; export { supabaseIntegration } from './supabase'; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts new file mode 100644 index 0000000000..aa2d36d0bc --- /dev/null +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -0,0 +1,50 @@ +import type { Integration } from '@sentry/core'; + +import { wrapTurboModule } from '../turbomodule'; +import { getRNSentryModule } from '../wrapper'; + +export const INTEGRATION_NAME = 'TurboModuleContext'; + +export interface TurboModuleContextOptions { + /** + * Additional TurboModules to track. Each entry's methods will be wrapped so + * that any native crash happening inside a method call gets `contexts.turbo_module` + * + `turbo_module.name` / `turbo_module.method` attached to the crash report. + * + * The built-in `RNSentry` TurboModule is always tracked. + */ + modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; +} + +// `addListener` / `removeListeners` are RN event-emitter stubs that fire on +// every subscriber registration — tracking them would just churn the scope. +const RNSENTRY_SKIP = ['addListener', 'removeListeners'] as const; + +/** + * Attaches the currently-executing TurboModule method to the Sentry scope so + * that native crashes can be attributed to the high-level RN module + method + * (e.g. `RNSentry.captureEnvelope`) on top of the native stack trace. + * + * The active call is mirrored as `contexts.turbo_module` and the + * `turbo_module.name` / `turbo_module.method` tags, both of which are already + * synced to the native SDKs by the existing scope-sync hooks and therefore end + * up in crash reports captured by sentry-cocoa / sentry-java. + * + * See https://github.com/getsentry/sentry-react-native/issues/6163. + */ +export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // Wrap the live RNSentry TurboModule. Other integrations import the same + // instance by reference, so wrapping here transparently tracks every call + // made from JS — including the SDK's own internal envelope/scope sync + // calls, which are the most likely entry points for native crashes. + wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP }); + + for (const entry of options.modules ?? []) { + wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods }); + } + }, + }; +}; diff --git a/packages/core/src/js/turbomodule/index.ts b/packages/core/src/js/turbomodule/index.ts new file mode 100644 index 0000000000..f75620a1b2 --- /dev/null +++ b/packages/core/src/js/turbomodule/index.ts @@ -0,0 +1,8 @@ +export { + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, +} from './turboModuleTracker'; +export type { TurboModuleCall } from './turboModuleTracker'; +export { wrapTurboModule } from './wrapTurboModule'; diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts new file mode 100644 index 0000000000..295a6af852 --- /dev/null +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -0,0 +1,145 @@ +import type { Scope } from '@sentry/core'; + +import { getCurrentScope } from '@sentry/core'; + +/** + * Describes a single TurboModule method invocation currently in flight. + */ +export interface TurboModuleCall { + /** TurboModule name, e.g. `RNSentry`. */ + name: string; + /** Method name, e.g. `captureEnvelope`. */ + method: string; + /** Whether the invocation is `sync` (blocking) or `async` (returns a Promise). */ + kind: 'sync' | 'async'; + /** `Date.now()` at the moment the call started. */ + startedAtMs: number; + /** Monotonically increasing id, used as the JS-side `call_id` cross-reference. */ + callId: number; +} + +const CONTEXT_KEY = 'turbo_module'; +const TAG_NAME = 'turbo_module.name'; +const TAG_METHOD = 'turbo_module.method'; + +let nextCallId = 0; + +/** + * Stack of active TurboModule invocations. + * + * React Native's TurboModule perf logger fires `syncMethodCallStart/End` and + * `asyncMethodCallExecutionStart/End` from the thread executing the C++ method. + * In JS-land we don't have per-OS-thread storage, but the JS thread is single + * threaded — so a single shared stack faithfully models the active call chain + * for everything dispatched from JS. + * + * NOTE: This is an in-memory mirror only. For true async-signal-safety on the + * native crash path we'd want to also write a fixed-size ring buffer of + * `{module_id, method_id}` indexes into shared storage that sentry-cocoa / + * sentry-java can read from a signal handler. The current implementation relies + * on the native SDKs' existing scope mirroring (which serialises `contexts` and + * `tags` for crash reports) — this covers crashes that happen *after* the + * scope update is flushed but is not strictly async-signal-safe. + */ +const stack: TurboModuleCall[] = []; + +/** + * Returns the active TurboModule call (top of stack), or `undefined` if no + * TurboModule call is currently being tracked. + */ +export function getActiveTurboModuleCall(): TurboModuleCall | undefined { + return stack[stack.length - 1]; +} + +/** + * Returns a copy of the current TurboModule call stack, top-most call last. + * Exposed for tests and diagnostics. + */ +export function getTurboModuleCallStack(): TurboModuleCall[] { + return stack.slice(); +} + +/** + * Resets the tracker. Tests only. + */ +export function _resetTurboModuleTracker(): void { + stack.length = 0; + nextCallId = 0; +} + +/** + * Records the start of a TurboModule method invocation and mirrors it onto the + * current Sentry scope so that any crash report captured during the call + * carries `contexts.turbo_module` + `turbo_module.*` tags. + * + * Returns the assigned `callId`, to be passed back into {@link popTurboModuleCall}. + */ +export function pushTurboModuleCall(args: { + name: string; + method: string; + kind: 'sync' | 'async'; + scope?: Scope; +}): number { + const call: TurboModuleCall = { + name: args.name, + method: args.method, + kind: args.kind, + startedAtMs: Date.now(), + callId: nextCallId++, + }; + + stack.push(call); + syncToScope(call, args.scope); + return call.callId; +} + +/** + * Records the end of a TurboModule method invocation previously started with + * {@link pushTurboModuleCall}. Pops the matching frame off the stack and + * updates the Sentry scope to point at the new top (or clears the context if + * the stack is now empty). + * + * `callId` is the value returned by `pushTurboModuleCall`. If the call cannot + * be found (e.g. due to a misuse / race), the pop is a no-op. + */ +export function popTurboModuleCall(callId: number, scope?: Scope): void { + // The common case is a perfectly nested LIFO — pop from the end. + const top = stack[stack.length - 1]; + if (top?.callId === callId) { + stack.pop(); + } else { + // Out-of-order completion (async). Find and splice. + const index = stack.findIndex(c => c.callId === callId); + if (index < 0) { + return; + } + stack.splice(index, 1); + } + + const newTop = stack[stack.length - 1]; + if (newTop) { + syncToScope(newTop, scope); + } else { + clearScope(scope); + } +} + +function syncToScope(call: TurboModuleCall, scope?: Scope): void { + const target = scope ?? getCurrentScope(); + target.setContext(CONTEXT_KEY, { + name: call.name, + method: call.method, + kind: call.kind, + started_at_ms: call.startedAtMs, + call_id: call.callId, + }); + target.setTag(TAG_NAME, call.name); + target.setTag(TAG_METHOD, call.method); +} + +function clearScope(scope?: Scope): void { + const target = scope ?? getCurrentScope(); + target.setContext(CONTEXT_KEY, null); + target.setTag(TAG_NAME, undefined); + target.setTag(TAG_METHOD, undefined); +} diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts new file mode 100644 index 0000000000..216bedad74 --- /dev/null +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -0,0 +1,119 @@ +import { logger } from '@sentry/react'; + +import { popTurboModuleCall, pushTurboModuleCall } from './turboModuleTracker'; + +const WRAPPED_FLAG = '__sentryTurboModuleWrapped__'; + +/** + * Marker added to wrapped modules so we never double-wrap (which would push the + * same call twice onto the tracker stack). + */ +interface MaybeWrapped { + [WRAPPED_FLAG]?: boolean; +} + +/** + * Wraps every function-valued property on the given TurboModule so that each + * invocation is recorded on the Sentry TurboModule tracker. Returns the same + * `module` reference for chaining convenience. + * + * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. + * - Async methods (those returning a thenable) are tracked as `kind: 'async'` + * and popped when the returned promise settles. + * + * `skip` can be used to opt specific method names out of tracking (e.g. very + * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter + * stubs which would otherwise pollute the scope). + */ +export function wrapTurboModule( + name: string, + module: T | null | undefined, + options: { skip?: ReadonlyArray } = {}, +): T | null | undefined { + if (!module) { + return module; + } + + const maybeWrapped = module as T & MaybeWrapped; + if (maybeWrapped[WRAPPED_FLAG]) { + return module; + } + + const skip = new Set(options.skip ?? []); + + const target = module as unknown as Record; + for (const key of Object.keys(target)) { + if (skip.has(key)) { + continue; + } + const original = target[key]; + if (typeof original !== 'function') { + continue; + } + const originalFn = original as (...a: unknown[]) => unknown; + + target[key] = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + // We don't know yet whether `original` is sync or async — start optimistic + // as sync, upgrade the scope context if the result is thenable. + const callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); + let result: unknown; + try { + result = originalFn.apply(this, args); + } catch (e) { + popTurboModuleCall(callId); + throw e; + } + + if (isThenable(result)) { + // Re-record as async — clearer in the report. We just overwrite the + // existing tracker frame in place by popping + re-pushing with a fresh + // id would lose ordering, so instead we leave the stack frame alone + // and only relabel for the scope on completion (it's the *active* + // call's `kind` that ends up in `contexts.turbo_module`, and the + // outer perf-logger driven users can push with `kind: 'async'` + // directly when they know up front). + return (result as Promise).then( + value => { + popTurboModuleCall(callId); + return value; + }, + err => { + popTurboModuleCall(callId); + throw err; + }, + ); + } + + popTurboModuleCall(callId); + return result; + }; + } + + try { + Object.defineProperty(module, WRAPPED_FLAG, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + } catch (e) { + // Some TurboModule proxies are sealed — that's fine, we still patched the + // methods, but a second wrap call would be a no-op anyway because the + // properties now point at our wrappers (re-wrapping would still push + // through to `original` which is itself a wrapper, but the per-call + // pushes would double up). Log so this is visible during development. + logger.warn( + `[TurboModuleTracker] Could not mark ${name} as wrapped — repeated wrapping would double-track invocations.`, + ); + } + + return module; +} + +function isThenable(value: unknown): value is PromiseLike { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + const then = (value as { then?: unknown }).then; + return typeof then === 'function'; +} diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts new file mode 100644 index 0000000000..b98214fb5e --- /dev/null +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -0,0 +1,55 @@ +import { Scope } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; + +import { turboModuleContextIntegration } from '../../src/js/integrations/turboModuleContext'; +import * as turboModule from '../../src/js/turbomodule'; +import * as wrapper from '../../src/js/wrapper'; + +describe('turboModuleContextIntegration', () => { + let scope: Scope; + + beforeEach(() => { + scope = new Scope(); + jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wraps the live RNSentry TurboModule on setup', () => { + const fakeModule = { + addListener: jest.fn(), + removeListeners: jest.fn(), + crash: jest.fn(), + }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration().setupOnce!(); + + expect(wrapSpy).toHaveBeenCalledWith('RNSentry', fakeModule, { + skip: ['addListener', 'removeListeners'], + }); + }); + + it('wraps additional modules supplied via options', () => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + + const fakeOther = { run: jest.fn() }; + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration({ + modules: [{ name: 'Other', module: fakeOther, skipMethods: ['ignored'] }], + }).setupOnce!(); + + expect(wrapSpy).toHaveBeenCalledWith('Other', fakeOther, { skip: ['ignored'] }); + }); + + it('tolerates a missing RNSentry module', () => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + + expect(() => turboModuleContextIntegration().setupOnce!()).not.toThrow(); + }); +}); diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts new file mode 100644 index 0000000000..e42bb4fab2 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -0,0 +1,101 @@ +import { Scope } from '@sentry/core'; + +import { + _resetTurboModuleTracker, + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, +} from '../../src/js/turbomodule/turboModuleTracker'; + +describe('turboModuleTracker', () => { + let scope: Scope; + + beforeEach(() => { + _resetTurboModuleTracker(); + scope = new Scope(); + }); + + it('starts empty', () => { + expect(getActiveTurboModuleCall()).toBeUndefined(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pushes a call and exposes it on the scope', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + + const active = getActiveTurboModuleCall(); + expect(active).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + callId: id, + }); + expect(typeof active!.startedAtMs).toBe('number'); + + const ctx = scope.getScopeData().contexts.turbo_module; + expect(ctx).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + call_id: id, + }); + expect(scope.getScopeData().tags).toMatchObject({ + 'turbo_module.name': 'RNSentry', + 'turbo_module.method': 'captureEnvelope', + }); + }); + + it('clears the scope when the stack drains', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'crash', kind: 'sync', scope }); + popTurboModuleCall(id, scope); + + expect(getActiveTurboModuleCall()).toBeUndefined(); + expect(scope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(scope.getScopeData().tags['turbo_module.name']).toBeUndefined(); + expect(scope.getScopeData().tags['turbo_module.method']).toBeUndefined(); + }); + + it('exposes the new top of stack after popping a nested call', () => { + const outer = pushTurboModuleCall({ name: 'RNSentry', method: 'outer', kind: 'sync', scope }); + const inner = pushTurboModuleCall({ name: 'RNSentry', method: 'inner', kind: 'sync', scope }); + + expect(scope.getScopeData().tags['turbo_module.method']).toBe('inner'); + + popTurboModuleCall(inner, scope); + + expect(getActiveTurboModuleCall()?.callId).toBe(outer); + expect(scope.getScopeData().tags['turbo_module.method']).toBe('outer'); + + popTurboModuleCall(outer, scope); + expect(getActiveTurboModuleCall()).toBeUndefined(); + }); + + it('handles out-of-order async completion', () => { + const first = pushTurboModuleCall({ name: 'RNSentry', method: 'first', kind: 'async', scope }); + const second = pushTurboModuleCall({ name: 'RNSentry', method: 'second', kind: 'async', scope }); + + // Inner async finishes first — pop the outer one. + popTurboModuleCall(first, scope); + + expect(getTurboModuleCallStack().map(c => c.callId)).toEqual([second]); + expect(scope.getScopeData().tags['turbo_module.method']).toBe('second'); + }); + + it('is a no-op when popping an unknown id', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); + + popTurboModuleCall(9999, scope); + + expect(getActiveTurboModuleCall()?.callId).toBe(id); + }); + + it('assigns monotonically increasing call ids', () => { + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope }); + const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope }); + const c = pushTurboModuleCall({ name: 'M', method: 'c', kind: 'sync', scope }); + + expect(b).toBe(a + 1); + expect(c).toBe(b + 1); + }); +}); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts new file mode 100644 index 0000000000..14869be076 --- /dev/null +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -0,0 +1,129 @@ +import * as SentryCore from '@sentry/core'; +import { Scope } from '@sentry/core'; + +import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; +import { wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; + +describe('wrapTurboModule', () => { + let scope: Scope; + + beforeEach(() => { + _resetTurboModuleTracker(); + scope = new Scope(); + jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns null/undefined modules unchanged', () => { + expect(wrapTurboModule('X', null)).toBeNull(); + expect(wrapTurboModule('X', undefined)).toBeUndefined(); + }); + + it('tracks sync method calls and pops after completion', () => { + const seenDuringCall: ReturnType = []; + const module = { + doStuff: (a: number, b: number): number => { + seenDuringCall.push(...getTurboModuleCallStack()); + return a + b; + }, + }; + + wrapTurboModule('Mod', module); + + const result = module.doStuff(2, 3); + + expect(result).toBe(5); + expect(seenDuringCall).toHaveLength(1); + expect(seenDuringCall[0]).toMatchObject({ name: 'Mod', method: 'doStuff', kind: 'sync' }); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pops on synchronous throw', () => { + const module = { + explode: () => { + throw new Error('boom'); + }, + }; + + wrapTurboModule('Mod', module); + + expect(() => module.explode()).toThrow('boom'); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('tracks async method calls until the promise settles', async () => { + let resolveCall: (value: string) => void = () => undefined; + const module = { + asyncOp: () => + new Promise(resolve => { + resolveCall = resolve; + }), + }; + + wrapTurboModule('Mod', module); + + const promise = module.asyncOp(); + expect(getTurboModuleCallStack()).toHaveLength(1); + + resolveCall('done'); + await promise; + + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pops when an async method rejects', async () => { + const module = { + asyncFail: () => Promise.reject(new Error('nope')), + }; + + wrapTurboModule('Mod', module); + + await expect(module.asyncFail()).rejects.toThrow('nope'); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('skips methods listed in the skip option', () => { + let seen: ReturnType = []; + const module = { + addListener: () => undefined, + doStuff: () => { + seen = getTurboModuleCallStack(); + }, + }; + + wrapTurboModule('Mod', module, { skip: ['addListener'] }); + + module.addListener(); + expect(getTurboModuleCallStack()).toEqual([]); + + module.doStuff(); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ name: 'Mod', method: 'doStuff' }); + }); + + it('does not re-wrap an already wrapped module', () => { + const module = { + doStuff: () => undefined, + }; + wrapTurboModule('Mod', module); + const wrappedOnce = module.doStuff; + wrapTurboModule('Mod', module); + + expect(module.doStuff).toBe(wrappedOnce); + }); + + it('ignores non-function properties', () => { + const module: { version: string; doStuff: () => number } = { + version: '1.0.0', + doStuff: () => 42, + }; + + wrapTurboModule('Mod', module); + + expect(module.version).toBe('1.0.0'); + expect(module.doStuff()).toBe(42); + }); +}); From de4c63d6689a2b944423d9b693c48bc3b1e829bb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 3 Jun 2026 13:29:30 +0200 Subject: [PATCH 2/9] fixes --- CHANGELOG.md | 1 + .../src/js/turbomodule/turboModuleTracker.ts | 20 ++++ .../src/js/turbomodule/wrapTurboModule.ts | 92 +++++++++++-------- .../turbomodule/turboModuleTracker.test.ts | 27 ++++++ .../test/turbomodule/wrapTurboModule.test.ts | 71 +++++++++++++- 5 files changed, 172 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b27c9e49..9a8ba5286b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) +- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index 295a6af852..d3dc8bd482 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -93,6 +93,26 @@ export function pushTurboModuleCall(args: { return call.callId; } +/** + * Updates the `kind` of a previously-pushed call (in place) and re-syncs the + * scope if the call is currently the active one. Used by + * {@link wrapTurboModule} once it discovers that a method's return value is + * thenable. + * + * Returns `true` if the call was found and relabelled. + */ +export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async', scope?: Scope): boolean { + const call = stack.find(c => c.callId === callId); + if (!call || call.kind === kind) { + return !!call; + } + call.kind = kind; + if (stack[stack.length - 1] === call) { + syncToScope(call, scope); + } + return true; +} + /** * Records the end of a TurboModule method invocation previously started with * {@link pushTurboModuleCall}. Pops the matching frame off the stack and diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 216bedad74..1ec4b810f8 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -1,15 +1,16 @@ import { logger } from '@sentry/react'; -import { popTurboModuleCall, pushTurboModuleCall } from './turboModuleTracker'; - -const WRAPPED_FLAG = '__sentryTurboModuleWrapped__'; +import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; /** - * Marker added to wrapped modules so we never double-wrap (which would push the - * same call twice onto the tracker stack). + * Modules we've already wrapped. Tracked off-module so that even sealed proxies + * (which can't accept a marker property) are protected from double-wrapping. */ -interface MaybeWrapped { - [WRAPPED_FLAG]?: boolean; +let wrappedModules = new WeakSet(); + +/** Tests only. */ +export function _resetWrappedModules(): void { + wrappedModules = new WeakSet(); } /** @@ -18,8 +19,8 @@ interface MaybeWrapped { * `module` reference for chaining convenience. * * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. - * - Async methods (those returning a thenable) are tracked as `kind: 'async'` - * and popped when the returned promise settles. + * - Async methods (those returning a thenable) are relabelled to `kind: 'async'` + * right after the call dispatches and popped when the returned promise settles. * * `skip` can be used to opt specific method names out of tracking (e.g. very * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter @@ -34,15 +35,24 @@ export function wrapTurboModule( return module; } - const maybeWrapped = module as T & MaybeWrapped; - if (maybeWrapped[WRAPPED_FLAG]) { + if (wrappedModules.has(module)) { return module; } + wrappedModules.add(module); const skip = new Set(options.skip ?? []); + const methodNames = collectMethodNames(module); + + if (methodNames.length === 0) { + logger.warn( + `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, + ); + return module; + } const target = module as unknown as Record; - for (const key of Object.keys(target)) { + for (const key of methodNames) { if (skip.has(key)) { continue; } @@ -52,9 +62,9 @@ export function wrapTurboModule( } const originalFn = original as (...a: unknown[]) => unknown; - target[key] = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { // We don't know yet whether `original` is sync or async — start optimistic - // as sync, upgrade the scope context if the result is thenable. + // as sync, relabel to 'async' if the result turns out to be thenable. const callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); let result: unknown; try { @@ -65,13 +75,7 @@ export function wrapTurboModule( } if (isThenable(result)) { - // Re-record as async — clearer in the report. We just overwrite the - // existing tracker frame in place by popping + re-pushing with a fresh - // id would lose ordering, so instead we leave the stack frame alone - // and only relabel for the scope on completion (it's the *active* - // call's `kind` that ends up in `contexts.turbo_module`, and the - // outer perf-logger driven users can push with `kind: 'async'` - // directly when they know up front). + relabelTurboModuleCallKind(callId, 'async'); return (result as Promise).then( value => { popTurboModuleCall(callId); @@ -87,29 +91,41 @@ export function wrapTurboModule( popTurboModuleCall(callId); return result; }; - } - try { - Object.defineProperty(module, WRAPPED_FLAG, { - value: true, - enumerable: false, - configurable: false, - writable: false, - }); - } catch (e) { - // Some TurboModule proxies are sealed — that's fine, we still patched the - // methods, but a second wrap call would be a no-op anyway because the - // properties now point at our wrappers (re-wrapping would still push - // through to `original` which is itself a wrapper, but the per-call - // pushes would double up). Log so this is visible during development. - logger.warn( - `[TurboModuleTracker] Could not mark ${name} as wrapped — repeated wrapping would double-track invocations.`, - ); + try { + target[key] = wrapper; + } catch { + // Sealed / non-writable property — can't intercept this method, but we + // can still wrap the rest. Skip silently; the module-level method-count + // check above is the cliff that catches the "wrapped nothing" case. + } } return module; } +/** + * Returns the union of own + prototype-chain method names on `module`, + * deduplicated and skipping `Object.prototype`. Walking the prototype chain is + * necessary for JSI HostObject-backed TurboModule proxies under RN's New + * Architecture, which can expose methods via the proto chain rather than as + * own enumerable properties. + */ +function collectMethodNames(module: object): string[] { + const seen = new Set(); + let current: object | null = module; + while (current && current !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(current)) { + if (key === 'constructor') { + continue; + } + seen.add(key); + } + current = Object.getPrototypeOf(current) as object | null; + } + return Array.from(seen); +} + function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index e42bb4fab2..6bf0f8b3ac 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -6,6 +6,7 @@ import { getTurboModuleCallStack, popTurboModuleCall, pushTurboModuleCall, + relabelTurboModuleCallKind, } from '../../src/js/turbomodule/turboModuleTracker'; describe('turboModuleTracker', () => { @@ -90,6 +91,32 @@ describe('turboModuleTracker', () => { expect(getActiveTurboModuleCall()?.callId).toBe(id); }); + it('relabels the active call kind and re-syncs the scope', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'sync', scope }); + + const relabelled = relabelTurboModuleCallKind(id, 'async', scope); + + expect(relabelled).toBe(true); + expect(getActiveTurboModuleCall()?.kind).toBe('async'); + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ kind: 'async' }); + }); + + it('relabels a non-top frame without touching the scope context', () => { + const outer = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); + pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); + + relabelTurboModuleCallKind(outer, 'async', scope); + + // outer was relabelled + expect(getTurboModuleCallStack().find(c => c.callId === outer)?.kind).toBe('async'); + // but the active scope context still describes the top of stack ('inner', sync) + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ method: 'inner', kind: 'sync' }); + }); + + it('relabel returns false for an unknown id', () => { + expect(relabelTurboModuleCallKind(9999, 'async')).toBe(false); + }); + it('assigns monotonically increasing call ids', () => { const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope }); const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 14869be076..378224c8b7 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -2,13 +2,14 @@ import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; -import { wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; +import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; describe('wrapTurboModule', () => { let scope: Scope; beforeEach(() => { _resetTurboModuleTracker(); + _resetWrappedModules(); scope = new Scope(); jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); }); @@ -74,6 +75,26 @@ describe('wrapTurboModule', () => { expect(getTurboModuleCallStack()).toEqual([]); }); + it('relabels async calls to kind="async" once the call returns a thenable', () => { + let resolveCall: (value: string) => void = () => undefined; + const module = { + asyncOp: () => + new Promise(resolve => { + resolveCall = resolve; + }), + }; + + wrapTurboModule('Mod', module); + + const promise = module.asyncOp(); + + expect(getTurboModuleCallStack()[0]).toMatchObject({ method: 'asyncOp', kind: 'async' }); + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ method: 'asyncOp', kind: 'async' }); + + resolveCall('done'); + return promise; + }); + it('pops when an async method rejects', async () => { const module = { asyncFail: () => Promise.reject(new Error('nope')), @@ -115,6 +136,54 @@ describe('wrapTurboModule', () => { expect(module.doStuff).toBe(wrappedOnce); }); + it('does not double-wrap a sealed module on repeated calls', () => { + const module: { doStuff: () => void } = { + doStuff: () => undefined, + }; + wrapTurboModule('Mod', module); + Object.seal(module); + + // Repeated wrap attempts must be no-ops, otherwise every real call would + // push the same frame multiple times onto the tracker stack. + wrapTurboModule('Mod', module); + wrapTurboModule('Mod', module); + + module.doStuff(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('discovers methods exposed via the prototype chain (JSI HostObject shape)', () => { + let stackDuringCall: ReturnType = []; + class HostObjectLike { + public doStuff(): string { + stackDuringCall = getTurboModuleCallStack(); + return 'ok'; + } + } + const module = new HostObjectLike(); + + // sanity: methods are exposed via the prototype, not as own properties + expect(Object.keys(module)).toEqual([]); + + wrapTurboModule('HostObj', module); + + const result = module.doStuff(); + + expect(result).toBe('ok'); + expect(stackDuringCall).toHaveLength(1); + expect(stackDuringCall[0]).toMatchObject({ name: 'HostObj', method: 'doStuff' }); + }); + + it('warns and bails out cleanly when no methods are discoverable', () => { + const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + + const opaque = Object.create(null) as object; + + wrapTurboModule('Opaque', opaque); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No methods discovered on 'Opaque'")); + }); + it('ignores non-function properties', () => { const module: { version: string; doStuff: () => number } = { version: '1.0.0', From 4f68d95c5e466b25d4179700abc85ec16784d316 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 3 Jun 2026 17:31:18 +0200 Subject: [PATCH 3/9] fix(turbomodule): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses five review comments on the TurboModule crash-time context PR (#6227): - Pin the Scope on each tracker frame at push time. `popTurboModuleCall` and `relabelTurboModuleCallKind` now always operate against the scope captured at push, so an async call that spans a scope switch (`withScope`, isolation-scope swaps) clears the right scope instead of leaking stale `turbo_module` context onto the original. `pop` no longer takes a `scope` argument — it was a footgun. - Replace `setTag(key, undefined)` on clear with an empty-string sentinel. The native `setTag(key: string, value: string)` TurboModule spec requires a string; `undefined` would either be rejected or silently dropped at the bridge. `setContext(key, null)` remains the canonical "no active call" signal; the empty tags exist only to scrub stale `name`/`method` from the previous call. - Defer `wrappedModules.add(module)` until after at least one method has been successfully wrapped. Previously, a JSI HostObject whose methods materialise lazily was permanently locked out after the first (empty) discovery pass — subsequent calls short-circuited even though the module had since gained methods. - Strengthen the sealed-module test to spy on `pushTurboModuleCall` and assert exactly one push per real call. The previous assertion only checked the post-call stack was empty, which is also true under double-wrapping (each wrapper pushes and pops symmetrically). - Move the changelog entry from the already-released `## 8.13.0` section into `## Unreleased`. --- CHANGELOG.md | 2 +- packages/core/etc/sentry-react-native.api.md | 2 +- .../src/js/turbomodule/turboModuleTracker.ts | 68 ++++++++++++------- .../src/js/turbomodule/wrapTurboModule.ts | 15 +++- .../turbomodule/turboModuleTracker.test.ts | 38 ++++++++--- .../test/turbomodule/wrapTurboModule.test.ts | 32 ++++++++- 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0d6be821..3568ad2ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Instrument Expo Router `push`, `replace`, `navigate`, `back`, and `dismiss` (in addition to `prefetch`) with breadcrumbs and spans, and tag the resulting idle navigation span with the initiating `navigation.method` ([#6221](https://github.com/getsentry/sentry-react-native/pull/6221)) - Note: Expo Router span/breadcrumb attributes that may contain user identifiers (`route.href`, `route.params`, and concrete pathnames derived from string hrefs such as `/users/42`) are now gated behind `sendDefaultPii`. When `sendDefaultPii` is off (the default), prefetch spans for string hrefs use `route.name: 'unknown'` and omit `route.href`. Templated object hrefs (e.g. `{ pathname: '/users/[id]' }`) are unaffected. - Warn when Gradle resolves `sentry-android` to a version incompatible with the SDK ([#6238](https://github.com/getsentry/sentry-react-native/pull/6238)) +- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes @@ -44,7 +45,6 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) -- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 4402fc0985..510bc4be6b 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -537,7 +537,7 @@ export { OpenAiOptions } export function pauseAppHangTracking(): void; // @public -export function popTurboModuleCall(callId: number, scope?: Scope): void; +export function popTurboModuleCall(callId: number): void; // @public export const primitiveTagIntegration: () => Integration; diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index d3dc8bd482..06dfc2a060 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -18,6 +18,16 @@ export interface TurboModuleCall { callId: number; } +interface InternalCall extends TurboModuleCall { + /** + * Scope captured at push time. We pin it so that an async call which spans a + * scope switch (`withScope`, isolation-scope swaps, …) pops the *same* scope + * it pushed onto — otherwise we'd clear `turbo_module` on the wrong scope and + * leave stale data on the original. + */ + scope: Scope; +} + const CONTEXT_KEY = 'turbo_module'; const TAG_NAME = 'turbo_module.name'; const TAG_METHOD = 'turbo_module.method'; @@ -41,7 +51,7 @@ let nextCallId = 0; * `tags` for crash reports) — this covers crashes that happen *after* the * scope update is flushed but is not strictly async-signal-safe. */ -const stack: TurboModuleCall[] = []; +const stack: InternalCall[] = []; /** * Returns the active TurboModule call (top of stack), or `undefined` if no @@ -80,16 +90,17 @@ export function pushTurboModuleCall(args: { kind: 'sync' | 'async'; scope?: Scope; }): number { - const call: TurboModuleCall = { + const call: InternalCall = { name: args.name, method: args.method, kind: args.kind, startedAtMs: Date.now(), callId: nextCallId++, + scope: args.scope ?? getCurrentScope(), }; stack.push(call); - syncToScope(call, args.scope); + syncToScope(call); return call.callId; } @@ -101,14 +112,14 @@ export function pushTurboModuleCall(args: { * * Returns `true` if the call was found and relabelled. */ -export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async', scope?: Scope): boolean { +export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async'): boolean { const call = stack.find(c => c.callId === callId); if (!call || call.kind === kind) { return !!call; } call.kind = kind; if (stack[stack.length - 1] === call) { - syncToScope(call, scope); + syncToScope(call); } return true; } @@ -122,44 +133,55 @@ export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async * `callId` is the value returned by `pushTurboModuleCall`. If the call cannot * be found (e.g. due to a misuse / race), the pop is a no-op. */ -export function popTurboModuleCall(callId: number, scope?: Scope): void { +export function popTurboModuleCall(callId: number): void { // The common case is a perfectly nested LIFO — pop from the end. + let popped: InternalCall | undefined; const top = stack[stack.length - 1]; if (top?.callId === callId) { - stack.pop(); + popped = stack.pop(); } else { // Out-of-order completion (async). Find and splice. const index = stack.findIndex(c => c.callId === callId); if (index < 0) { return; } - stack.splice(index, 1); + [popped] = stack.splice(index, 1); } - const newTop = stack[stack.length - 1]; - if (newTop) { - syncToScope(newTop, scope); - } else { - clearScope(scope); + // Always clear / update on the scope the call was pushed against — the + // current scope may have changed in the meantime (async, withScope, …). + if (popped) { + const newTop = stack[stack.length - 1]; + if (newTop && newTop.scope === popped.scope) { + syncToScope(newTop); + } else { + clearScope(popped.scope); + } } } -function syncToScope(call: TurboModuleCall, scope?: Scope): void { - const target = scope ?? getCurrentScope(); - target.setContext(CONTEXT_KEY, { +function syncToScope(call: InternalCall): void { + call.scope.setContext(CONTEXT_KEY, { name: call.name, method: call.method, kind: call.kind, started_at_ms: call.startedAtMs, call_id: call.callId, }); - target.setTag(TAG_NAME, call.name); - target.setTag(TAG_METHOD, call.method); + call.scope.setTag(TAG_NAME, call.name); + call.scope.setTag(TAG_METHOD, call.method); } -function clearScope(scope?: Scope): void { - const target = scope ?? getCurrentScope(); - target.setContext(CONTEXT_KEY, null); - target.setTag(TAG_NAME, undefined); - target.setTag(TAG_METHOD, undefined); +// Empty-string sentinel for the "no active call" state. We don't pass +// `undefined` because the native `setTag(key, value)` TurboModule spec +// requires a string — the bridge would otherwise see `undefined` and either +// throw or silently drop the call. `setContext(CONTEXT_KEY, null)` is the +// canonical "no active call" signal; the empty tags exist only so the tag set +// doesn't carry stale `name`/`method` from the previous call. +const NO_ACTIVE_CALL = ''; + +function clearScope(scope: Scope): void { + scope.setContext(CONTEXT_KEY, null); + scope.setTag(TAG_NAME, NO_ACTIVE_CALL); + scope.setTag(TAG_METHOD, NO_ACTIVE_CALL); } diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 1ec4b810f8..6f32dbcb97 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -38,12 +38,13 @@ export function wrapTurboModule( if (wrappedModules.has(module)) { return module; } - wrappedModules.add(module); const skip = new Set(options.skip ?? []); const methodNames = collectMethodNames(module); if (methodNames.length === 0) { + // Do NOT add to wrappedModules — a later call (e.g. once a JSI HostObject + // has materialised its methods) should still get a chance to wrap. logger.warn( `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, @@ -51,6 +52,7 @@ export function wrapTurboModule( return module; } + let wrappedAny = false; const target = module as unknown as Record; for (const key of methodNames) { if (skip.has(key)) { @@ -94,13 +96,20 @@ export function wrapTurboModule( try { target[key] = wrapper; + wrappedAny = true; } catch { // Sealed / non-writable property — can't intercept this method, but we - // can still wrap the rest. Skip silently; the module-level method-count - // check above is the cliff that catches the "wrapped nothing" case. + // can still wrap the rest. Skip silently. } } + // Only mark as wrapped if we actually installed at least one wrapper. + // Otherwise a future call (e.g. after the proxy has materialised methods) + // should be allowed to retry. + if (wrappedAny) { + wrappedModules.add(module); + } + return module; } diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index 6bf0f8b3ac..97dad69df8 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -49,12 +49,32 @@ describe('turboModuleTracker', () => { it('clears the scope when the stack drains', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'crash', kind: 'sync', scope }); - popTurboModuleCall(id, scope); + popTurboModuleCall(id); expect(getActiveTurboModuleCall()).toBeUndefined(); expect(scope.getScopeData().contexts.turbo_module).toBeUndefined(); - expect(scope.getScopeData().tags['turbo_module.name']).toBeUndefined(); - expect(scope.getScopeData().tags['turbo_module.method']).toBeUndefined(); + // Tags are cleared to the empty-string sentinel (native `setTag` requires a string). + expect(scope.getScopeData().tags['turbo_module.name']).toBe(''); + expect(scope.getScopeData().tags['turbo_module.method']).toBe(''); + }); + + it('pops against the scope captured at push time, not the current scope', () => { + const pushScope = new Scope(); + const otherScope = new Scope(); + + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope: pushScope }); + expect(pushScope.getScopeData().contexts.turbo_module).toBeDefined(); + + // Simulate a scope switch happening before the async call settles. + // pop must still clear `pushScope`, not `otherScope`. + popTurboModuleCall(id); + + // Scope.setContext(key, null) removes the entry, so contexts.turbo_module is undefined after clear. + expect(pushScope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(pushScope.getScopeData().tags['turbo_module.name']).toBe(''); + // The unrelated scope was never written to. + expect(otherScope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(otherScope.getScopeData().tags['turbo_module.name']).toBeUndefined(); }); it('exposes the new top of stack after popping a nested call', () => { @@ -63,12 +83,12 @@ describe('turboModuleTracker', () => { expect(scope.getScopeData().tags['turbo_module.method']).toBe('inner'); - popTurboModuleCall(inner, scope); + popTurboModuleCall(inner); expect(getActiveTurboModuleCall()?.callId).toBe(outer); expect(scope.getScopeData().tags['turbo_module.method']).toBe('outer'); - popTurboModuleCall(outer, scope); + popTurboModuleCall(outer); expect(getActiveTurboModuleCall()).toBeUndefined(); }); @@ -77,7 +97,7 @@ describe('turboModuleTracker', () => { const second = pushTurboModuleCall({ name: 'RNSentry', method: 'second', kind: 'async', scope }); // Inner async finishes first — pop the outer one. - popTurboModuleCall(first, scope); + popTurboModuleCall(first); expect(getTurboModuleCallStack().map(c => c.callId)).toEqual([second]); expect(scope.getScopeData().tags['turbo_module.method']).toBe('second'); @@ -86,7 +106,7 @@ describe('turboModuleTracker', () => { it('is a no-op when popping an unknown id', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); - popTurboModuleCall(9999, scope); + popTurboModuleCall(9999); expect(getActiveTurboModuleCall()?.callId).toBe(id); }); @@ -94,7 +114,7 @@ describe('turboModuleTracker', () => { it('relabels the active call kind and re-syncs the scope', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'sync', scope }); - const relabelled = relabelTurboModuleCallKind(id, 'async', scope); + const relabelled = relabelTurboModuleCallKind(id, 'async'); expect(relabelled).toBe(true); expect(getActiveTurboModuleCall()?.kind).toBe('async'); @@ -105,7 +125,7 @@ describe('turboModuleTracker', () => { const outer = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); - relabelTurboModuleCallKind(outer, 'async', scope); + relabelTurboModuleCallKind(outer, 'async'); // outer was relabelled expect(getTurboModuleCallStack().find(c => c.callId === outer)?.kind).toBe('async'); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 378224c8b7..6dbabaada9 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; +import * as tracker from '../../src/js/turbomodule/turboModuleTracker'; import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; @@ -143,15 +144,42 @@ describe('wrapTurboModule', () => { wrapTurboModule('Mod', module); Object.seal(module); - // Repeated wrap attempts must be no-ops, otherwise every real call would - // push the same frame multiple times onto the tracker stack. + // Repeated wrap attempts must be no-ops — every real call should push + // exactly one frame onto the tracker, no matter how many times we wrapped. wrapTurboModule('Mod', module); wrapTurboModule('Mod', module); + const pushSpy = jest.spyOn(tracker, 'pushTurboModuleCall'); module.doStuff(); + expect(pushSpy).toHaveBeenCalledTimes(1); expect(getTurboModuleCallStack()).toEqual([]); }); + it('retries wrapping a previously-empty module (lazy JSI HostObject)', () => { + const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + + // First call: methods not yet materialised — should warn, NOT mark as wrapped. + const lazyModule: { doStuff?: () => string } = Object.create(null) as { doStuff?: () => string }; + wrapTurboModule('Lazy', lazyModule); + expect(warnSpy).toHaveBeenCalled(); + + // Methods materialise (simulating a HostObject's lazy method resolution). + let seenDuringCall: ReturnType = []; + lazyModule.doStuff = () => { + seenDuringCall = getTurboModuleCallStack(); + return 'ok'; + }; + + // Second wrap must now actually install a wrapper on the freshly-discovered method. + wrapTurboModule('Lazy', lazyModule); + + const result = lazyModule.doStuff(); + + expect(result).toBe('ok'); + expect(seenDuringCall).toHaveLength(1); + expect(seenDuringCall[0]).toMatchObject({ name: 'Lazy', method: 'doStuff' }); + }); + it('discovers methods exposed via the prototype chain (JSI HostObject shape)', () => { let stackDuringCall: ReturnType = []; class HostObjectLike { From 99eeacbf10bbaad00b96ad71690c3429a536a46d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 8 Jun 2026 10:22:48 +0200 Subject: [PATCH 4/9] fix(turbomodule): address remaining PR review feedback - Switch logger import to '@sentry/core' to match the convention used by the rest of the codebase (per @antonis review). - Guard the property read in wrapTurboModule's wrap loop. Some JSI HostObject proxies under the New Architecture expose methods as accessors, and a throwing getter would otherwise propagate out and abort wrapping of all remaining methods on the module. A throwing getter is now treated like a non-wrappable property and skipped. - Fix popTurboModuleCall scope handling when scopes interleave on the stack. Previously we only inspected the new top frame, so popping a call from a stack like [A@s1, B@s2, C@s1] would clear s1's turbo_module context even though A is still active on it. We now walk the stack for the newest remaining frame on popped.scope and re-sync onto it, only clearing the scope when no frames remain. Adds tests for the throwing-getter path and for both the preserve-on-interleave and clear-when-empty popTurboModuleCall cases. --- .../src/js/turbomodule/turboModuleTracker.ts | 19 ++++++-- .../src/js/turbomodule/wrapTurboModule.ts | 13 +++++- .../turbomodule/turboModuleTracker.test.ts | 44 +++++++++++++++++++ .../test/turbomodule/wrapTurboModule.test.ts | 29 +++++++++++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index 06dfc2a060..f3ce670a7b 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -150,10 +150,23 @@ export function popTurboModuleCall(callId: number): void { // Always clear / update on the scope the call was pushed against — the // current scope may have changed in the meantime (async, withScope, …). + // + // When scopes interleave on the stack (e.g. [A@s1, B@s2, C@s1] and we pop + // C), the immediate stack top is *not* the right thing to look at: there may + // still be a deeper frame holding `popped.scope` whose context we'd wipe by + // calling `clearScope`. Walk the stack from the top down and re-sync onto + // the newest remaining frame on `popped.scope`; only clear if none is left. if (popped) { - const newTop = stack[stack.length - 1]; - if (newTop && newTop.scope === popped.scope) { - syncToScope(newTop); + let remainingOnSameScope: InternalCall | undefined; + for (let i = stack.length - 1; i >= 0; i--) { + const frame = stack[i]; + if (frame && frame.scope === popped.scope) { + remainingOnSameScope = frame; + break; + } + } + if (remainingOnSameScope) { + syncToScope(remainingOnSameScope); } else { clearScope(popped.scope); } diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 6f32dbcb97..b1c099b4cb 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -1,4 +1,4 @@ -import { logger } from '@sentry/react'; +import { logger } from '@sentry/core'; import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; @@ -58,7 +58,16 @@ export function wrapTurboModule( if (skip.has(key)) { continue; } - const original = target[key]; + // `target[key]` may be a getter (some JSI HostObject proxies expose methods + // as accessors under the New Architecture). Guard the read so a throwing + // getter is treated like a non-wrappable property instead of aborting the + // entire wrap loop. + let original: unknown; + try { + original = target[key]; + } catch { + continue; + } if (typeof original !== 'function') { continue; } diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index 97dad69df8..0a2a1f4526 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -103,6 +103,50 @@ describe('turboModuleTracker', () => { expect(scope.getScopeData().tags['turbo_module.method']).toBe('second'); }); + it('preserves context on a scope that still has a deeper active call after interleaved pop', () => { + const scopeA = new Scope(); + const scopeB = new Scope(); + + // Stack: [A@scopeA, B@scopeB, C@scopeA]. Pop C — scopeA must keep its + // turbo_module context (because A is still active), and re-sync to A. + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope: scopeA }); + pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope: scopeB }); + const c = pushTurboModuleCall({ name: 'M', method: 'c', kind: 'sync', scope: scopeA }); + + expect(scopeA.getScopeData().tags['turbo_module.method']).toBe('c'); + + popTurboModuleCall(c); + + // scopeA still has an active frame (A) — must NOT be cleared, and must be + // re-synced to describe A, not C. + expect(scopeA.getScopeData().contexts.turbo_module).toMatchObject({ + name: 'M', + method: 'a', + call_id: a, + }); + expect(scopeA.getScopeData().tags['turbo_module.method']).toBe('a'); + + // scopeB still has its own active frame and is untouched. + expect(scopeB.getScopeData().tags['turbo_module.method']).toBe('b'); + }); + + it('clears a scope only when no frames remain on it after an interleaved pop', () => { + const scopeA = new Scope(); + const scopeB = new Scope(); + + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope: scopeA }); + const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope: scopeB }); + + // Pop the bottom frame (A). scopeA should clear; scopeB untouched. + popTurboModuleCall(a); + + expect(scopeA.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(scopeA.getScopeData().tags['turbo_module.name']).toBe(''); + expect(scopeB.getScopeData().tags['turbo_module.method']).toBe('b'); + + popTurboModuleCall(b); + }); + it('is a no-op when popping an unknown id', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 6dbabaada9..d09161ea9f 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -156,7 +156,7 @@ describe('wrapTurboModule', () => { }); it('retries wrapping a previously-empty module (lazy JSI HostObject)', () => { - const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); // First call: methods not yet materialised — should warn, NOT mark as wrapped. const lazyModule: { doStuff?: () => string } = Object.create(null) as { doStuff?: () => string }; @@ -203,7 +203,7 @@ describe('wrapTurboModule', () => { }); it('warns and bails out cleanly when no methods are discoverable', () => { - const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); const opaque = Object.create(null) as object; @@ -212,6 +212,31 @@ describe('wrapTurboModule', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No methods discovered on 'Opaque'")); }); + it('skips properties whose getter throws (JSI HostObject accessor edge case)', () => { + let stackDuringCall: ReturnType = []; + const module = { + doStuff: () => { + stackDuringCall = getTurboModuleCallStack(); + return 'ok'; + }, + }; + Object.defineProperty(module, 'throwingGetter', { + enumerable: true, + configurable: true, + get() { + throw new Error('getter boom'); + }, + }); + + // Wrapping must not propagate the getter's throw; wrappable methods on the + // same module should still be wrapped. + expect(() => wrapTurboModule('Mod', module)).not.toThrow(); + + expect(module.doStuff()).toBe('ok'); + expect(stackDuringCall).toHaveLength(1); + expect(stackDuringCall[0]).toMatchObject({ name: 'Mod', method: 'doStuff' }); + }); + it('ignores non-function properties', () => { const module: { version: string; doStuff: () => number } = { version: '1.0.0', From c8d960b612022d305b3c6a7007827e9abe7258c7 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 8 Jun 2026 13:49:15 +0200 Subject: [PATCH 5/9] fix(turbomodule): prevent scope-sync recursion and harden wrapper edges Addresses three review comments on #6227: - Skip all scope-sync methods on RNSentry (`setContext`, `setTag`, `setExtra`, `setUser`, `addBreadcrumb`, `clearBreadcrumbs`, `setAttribute`, `setAttributes`, `removeAttribute`) in addition to `addListener` / `removeListeners`. Without this the tracker recurses infinitely on every TurboModule call: `pushTurboModuleCall` -> `scope.setContext('turbo_module', ...)` -> `NATIVE.setContext` -> `RNSentry.setContext` (wrapped) -> `pushTurboModuleCall` -> ... . - Wrap the `.then` read in `isThenable` in try/catch. A user-defined throwing `then` getter on a method's return value would otherwise escape the wrapper and leak the in-flight tracker frame. - Warn when methods are discovered but none could be wrapped (e.g. `Object.freeze`d legacy `NativeModules.RNSentry` on the old architecture, or read-only accessors). Previously this was a silent no-op with no obvious cause for the missing crash context. Tests added: scope-sync methods are not wrapped on RNSentry, throwing `then` getter doesn't leak a frame, frozen module triggers the warning. --- .../src/js/integrations/turboModuleContext.ts | 27 ++++++++-- .../src/js/turbomodule/wrapTurboModule.ts | 19 ++++++- .../integrations/turboModuleContext.test.ts | 53 ++++++++++++++++++- .../test/turbomodule/wrapTurboModule.test.ts | 29 ++++++++++ 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index aa2d36d0bc..a577da3685 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -16,9 +16,30 @@ export interface TurboModuleContextOptions { modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; } -// `addListener` / `removeListeners` are RN event-emitter stubs that fire on -// every subscriber registration — tracking them would just churn the scope. -const RNSENTRY_SKIP = ['addListener', 'removeListeners'] as const; +// Methods on RNSentry that must NOT be tracked: +// +// - `addListener` / `removeListeners` are RN event-emitter stubs that fire on +// every subscriber registration — tracking them would just churn the scope. +// +// - The scope-sync methods (`setContext`, `setTag`, `setExtra`, `setUser`, +// `addBreadcrumb`, `clearBreadcrumbs`, `setAttribute`, `setAttributes`, +// `removeAttribute`) are called by our own `enableSyncToNative` hook every +// time anything writes to a JS Scope. Tracking them would cause infinite +// recursion: `pushTurboModuleCall` -> `scope.setContext` -> `NATIVE.setContext` +// -> `RNSentry.setContext` (wrapped) -> `pushTurboModuleCall` -> ... . +const RNSENTRY_SKIP = [ + 'addListener', + 'removeListeners', + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', +] as const; /** * Attaches the currently-executing TurboModule method to the Sentry scope so diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index b1c099b4cb..249ce12ddf 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -117,6 +117,15 @@ export function wrapTurboModule( // should be allowed to retry. if (wrappedAny) { wrappedModules.add(module); + } else { + // We discovered methods but couldn't intercept any of them — e.g. the + // module is frozen, or every method is a read-only accessor. Surface this + // so the silent no-op is debuggable (the issue would otherwise look like + // "no crash context attached" with no obvious cause). + logger.warn( + `[TurboModuleTracker] '${name}' has methods but none could be wrapped — TurboModule context will not be attached. ` + + `This usually means the module is frozen or its methods are non-writable accessors.`, + ); } return module; @@ -148,6 +157,12 @@ function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; } - const then = (value as { then?: unknown }).then; - return typeof then === 'function'; + // A user-defined `then` getter could throw — don't let that escape into the + // wrapper (which would leak the in-flight tracker frame on top of the bug). + try { + const then = (value as { then?: unknown }).then; + return typeof then === 'function'; + } catch { + return false; + } } diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index b98214fb5e..4b9f4a0bad 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -30,10 +30,61 @@ describe('turboModuleContextIntegration', () => { turboModuleContextIntegration().setupOnce!(); expect(wrapSpy).toHaveBeenCalledWith('RNSentry', fakeModule, { - skip: ['addListener', 'removeListeners'], + skip: [ + 'addListener', + 'removeListeners', + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', + ], }); }); + it('does not wrap scope-sync methods on RNSentry (would recurse infinitely)', () => { + // Sanity check: every method `scopeSync.ts` forwards to NATIVE.* via + // RNSentry must be in the skip list, otherwise scope writes recurse. + const fakeModule = { + setContext: jest.fn(), + setTag: jest.fn(), + setExtra: jest.fn(), + setUser: jest.fn(), + addBreadcrumb: jest.fn(), + clearBreadcrumbs: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), + removeAttribute: jest.fn(), + crash: jest.fn(), + }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + + turboModuleContextIntegration().setupOnce!(); + + // crash is wrapped (replaced), scope-sync methods are not (still the jest mocks). + expect(fakeModule.crash).not.toBe(jest.fn()); + for (const method of [ + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', + ] as const) { + // jest mocks expose `_isMockFunction` — if the method is still the + // original mock, it's intact; if it were our wrapper, that property + // would be missing. + expect(fakeModule[method]._isMockFunction).toBe(true); + } + }); + it('wraps additional modules supplied via options', () => { jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index d09161ea9f..b41f060c11 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -202,6 +202,35 @@ describe('wrapTurboModule', () => { expect(stackDuringCall[0]).toMatchObject({ name: 'HostObj', method: 'doStuff' }); }); + it('warns when methods are discovered but none could be wrapped (frozen module)', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + + const frozen = Object.freeze({ doStuff: () => 'ok' }); + + wrapTurboModule('Frozen', frozen); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'Frozen' has methods but none could be wrapped"), + ); + }); + + it('does not leak a tracker frame when the result has a throwing `then` getter', () => { + const trap = Object.defineProperty({}, 'then', { + get() { + throw new Error('boom'); + }, + }); + const module = { + weird: () => trap, + }; + + wrapTurboModule('Mod', module); + + // Must not throw, must not leave a frame on the stack. + expect(() => module.weird()).not.toThrow(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + it('warns and bails out cleanly when no methods are discoverable', () => { const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); From 4201154cbd8fdb31a3832be787c7e9feba6b72bd Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 8 Jun 2026 17:05:28 +0200 Subject: [PATCH 6/9] fix(turbomodule): keep native context in sync after cross-scope pop Addresses two unresolved review comments on #6227: - Native SDKs (sentry-cocoa, sentry-java) share a single scope, but JS has many Scope objects (global, isolation, withScope, ...). When a cross-scope pop runs `clearScope` on the popped scope, the scope-sync hook overwrites the native `turbo_module` context with null \u2014 even if the global stack top still lives on a different JS scope with an in-flight wrapped call. A crash following such a pop would land in native without the active TurboModule attribution. Fix: after updating the popped scope, re-sync the global stack top via its own scope so the native scope ends up holding the correct active call context. Dedup the write when the popped scope's new top *is* the global top. - Strengthen the `crash`-is-wrapped assertion in the recursion-guard test. `expect(fakeModule.crash).not.toBe(jest.fn())` was trivially true (every `jest.fn()` call returns a new reference), so it would have passed even if the integration didn't wrap `crash` at all. Capture the original mock before `setupOnce` and check the wrapper replaced it (and lacks the `_isMockFunction` marker). --- .../src/js/turbomodule/turboModuleTracker.ts | 60 ++++++++++++------- .../integrations/turboModuleContext.test.ts | 7 ++- .../turbomodule/turboModuleTracker.test.ts | 38 ++++++++++++ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index f3ce670a7b..bfae64a168 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -148,29 +148,47 @@ export function popTurboModuleCall(callId: number): void { [popped] = stack.splice(index, 1); } - // Always clear / update on the scope the call was pushed against — the - // current scope may have changed in the meantime (async, withScope, …). - // - // When scopes interleave on the stack (e.g. [A@s1, B@s2, C@s1] and we pop - // C), the immediate stack top is *not* the right thing to look at: there may - // still be a deeper frame holding `popped.scope` whose context we'd wipe by - // calling `clearScope`. Walk the stack from the top down and re-sync onto - // the newest remaining frame on `popped.scope`; only clear if none is left. - if (popped) { - let remainingOnSameScope: InternalCall | undefined; - for (let i = stack.length - 1; i >= 0; i--) { - const frame = stack[i]; - if (frame && frame.scope === popped.scope) { - remainingOnSameScope = frame; - break; - } - } - if (remainingOnSameScope) { - syncToScope(remainingOnSameScope); - } else { - clearScope(popped.scope); + if (!popped) { + return; + } + + // 1. Reflect the new state of `popped.scope` (the scope this call was pinned + // to). When scopes interleave on the stack (e.g. [A@s1, B@s2, C@s1] and + // we pop C), the immediate stack top is *not* the right thing to look at: + // there may still be a deeper frame holding `popped.scope` whose context + // we'd wipe by calling `clearScope`. Walk the stack from the top down and + // re-sync onto the newest remaining frame on `popped.scope`; only clear + // if none is left. + let remainingOnSameScope: InternalCall | undefined; + for (let i = stack.length - 1; i >= 0; i--) { + const frame = stack[i]; + if (frame && frame.scope === popped.scope) { + remainingOnSameScope = frame; + break; } } + if (remainingOnSameScope) { + syncToScope(remainingOnSameScope); + } else { + clearScope(popped.scope); + } + + // 2. The native SDKs (sentry-cocoa / sentry-java) share a *single* native + // scope, but JS has many Scope objects (global, isolation, withScope, …). + // Every `Scope#setContext` / `Scope#setTag` we just made in step 1 fired + // the `scopeSync.ts` hook and overwrote the native scope's `turbo_module` + // context — even if the global top of the stack still lives on a + // *different* JS scope. Without this second sync, a crash that follows a + // cross-scope pop would land in native without the active TurboModule + // attribution, even though the global stack still has an in-flight call. + // + // Re-sync the global stack top via *its* scope so native ends up holding + // the correct active-call context. The intermediate native write in step 1 + // is wasted but unavoidable without bypassing the public Scope API. + const globalTop = stack[stack.length - 1]; + if (globalTop && globalTop !== remainingOnSameScope) { + syncToScope(globalTop); + } } function syncToScope(call: InternalCall): void { diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 4b9f4a0bad..d0b6207159 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -63,10 +63,13 @@ describe('turboModuleContextIntegration', () => { }; jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + const originalCrash = fakeModule.crash; turboModuleContextIntegration().setupOnce!(); - // crash is wrapped (replaced), scope-sync methods are not (still the jest mocks). - expect(fakeModule.crash).not.toBe(jest.fn()); + // crash is wrapped (replaced with sentryTurboModuleWrapper, which is a plain + // function and therefore lacks the `_isMockFunction` marker the jest mocks carry). + expect(fakeModule.crash).not.toBe(originalCrash); + expect((fakeModule.crash as { _isMockFunction?: boolean })._isMockFunction).toBeUndefined(); for (const method of [ 'setContext', 'setTag', diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index 0a2a1f4526..f385eebf0e 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -58,6 +58,44 @@ describe('turboModuleTracker', () => { expect(scope.getScopeData().tags['turbo_module.method']).toBe(''); }); + it('re-syncs the global stack top to its scope after a cross-scope pop', () => { + // Native SDKs share one scope; when scope A is cleared on pop, the + // scopeSync hook would also wipe native. We must re-sync the global top + // (which lives on scope B) so native crash reports keep the active call. + const scopeA = new Scope(); + const scopeB = new Scope(); + + const aCallId = pushTurboModuleCall({ name: 'A', method: 'a', kind: 'sync', scope: scopeA }); + pushTurboModuleCall({ name: 'B', method: 'b', kind: 'sync', scope: scopeB }); + + const setContextSpy = jest.spyOn(scopeB, 'setContext'); + + popTurboModuleCall(aCallId); + + // scopeA's frame is gone, so scopeA's context was cleared (good for JS hygiene). + expect(scopeA.getScopeData().contexts.turbo_module).toBeUndefined(); + // scopeB still holds the active call — and we proactively re-synced it via + // scopeB.setContext so the shared native scope ends up holding B's data + // rather than the null left behind by scopeA's clear. + expect(scopeB.getScopeData().contexts.turbo_module).toMatchObject({ name: 'B', method: 'b' }); + expect(setContextSpy).toHaveBeenCalledWith('turbo_module', expect.objectContaining({ name: 'B', method: 'b' })); + }); + + it('does not redundantly re-sync the global top when popping leaves it on the same scope', () => { + const outerId = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); + const innerId = pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); + + const setContextSpy = jest.spyOn(scope, 'setContext'); + popTurboModuleCall(innerId); + + // Exactly one setContext call to restore the 'outer' frame on `scope`. + // (Without dedup, the global-top re-sync would fire a second redundant write.) + expect(setContextSpy).toHaveBeenCalledTimes(1); + expect(setContextSpy).toHaveBeenCalledWith('turbo_module', expect.objectContaining({ method: 'outer' })); + + popTurboModuleCall(outerId); + }); + it('pops against the scope captured at push time, not the current scope', () => { const pushScope = new Scope(); const otherScope = new Scope(); From 9689475617cd0a214f1a05df8465e4e22d770e5f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 9 Jun 2026 10:12:50 +0200 Subject: [PATCH 7/9] fix(turbomodule): never let instrumentation break user TurboModule calls Addresses the unresolved Warden finding on #6227. Previously, the wrapper called `pushTurboModuleCall` *outside* its `try` block. `pushTurboModuleCall` synchronously writes to the pinned scope, and `scopeSync.ts`'s hooked `setContext` calls into `NATIVE.setContext`, which throws `_NativeClientError` when the RNSentry module isn't loaded (and any other native bridge error along that path). A throw there would abort the wrapper before `originalFn.apply` was reached \u2014 silently dropping the user's real TurboModule call (including critical ones like `captureEnvelope`) and surfacing an unexpected error to the caller. It also left the just- pushed frame on the tracker stack with no matching pop, leaking the `turbo_module` context. Two-layer fix: - `pushTurboModuleCall` is now atomic. If `syncToScope` throws, the stack push is rolled back so the tracker stack stays consistent. - The wrapper isolates every tracker interaction (push, pop, relabel) in its own try/catch with a `logger.warn` on failure. The user's call is always executed and its result/exception is always returned to the caller. The worst case is loss of the `turbo_module` attribution for that call \u2014 never a broken user invocation. Tests added: atomic-push rollback on `syncToScope` throw; wrapper still calls the original method (and returns its result) when push or pop throws. --- .../src/js/turbomodule/turboModuleTracker.ts | 10 +++- .../src/js/turbomodule/wrapTurboModule.ts | 44 ++++++++++++++--- .../turbomodule/turboModuleTracker.test.ts | 14 ++++++ .../test/turbomodule/wrapTurboModule.test.ts | 47 +++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index bfae64a168..b0436a07d1 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -99,8 +99,16 @@ export function pushTurboModuleCall(args: { scope: args.scope ?? getCurrentScope(), }; + // Atomic push: if `syncToScope` throws (e.g. a scope-sync hook calls into a + // native bridge that rejects with `_NativeClientError`), roll back the stack + // push so we don't leak a frame. stack.push(call); - syncToScope(call); + try { + syncToScope(call); + } catch (e) { + stack.pop(); + throw e; + } return call.callId; } diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 249ce12ddf..48741e00cb 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -74,32 +74,42 @@ export function wrapTurboModule( const originalFn = original as (...a: unknown[]) => unknown; const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + // The instrumentation must never break the user's call — every tracker + // interaction is isolated so a failure inside Sentry only drops the + // attribution data, never the real TurboModule invocation. + // // We don't know yet whether `original` is sync or async — start optimistic // as sync, relabel to 'async' if the result turns out to be thenable. - const callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); + let callId: number | undefined; + try { + callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); + } catch (e) { + logger.warn(`[TurboModuleTracker] push failed for ${name}.${key}: ${String(e)}`); + } + let result: unknown; try { result = originalFn.apply(this, args); } catch (e) { - popTurboModuleCall(callId); + safePop(callId, name, key); throw e; } if (isThenable(result)) { - relabelTurboModuleCallKind(callId, 'async'); + safeRelabel(callId, 'async', name, key); return (result as Promise).then( value => { - popTurboModuleCall(callId); + safePop(callId, name, key); return value; }, err => { - popTurboModuleCall(callId); + safePop(callId, name, key); throw err; }, ); } - popTurboModuleCall(callId); + safePop(callId, name, key); return result; }; @@ -153,6 +163,28 @@ function collectMethodNames(module: object): string[] { return Array.from(seen); } +function safePop(callId: number | undefined, name: string, method: string): void { + if (callId === undefined) { + return; + } + try { + popTurboModuleCall(callId); + } catch (e) { + logger.warn(`[TurboModuleTracker] pop failed for ${name}.${method}: ${String(e)}`); + } +} + +function safeRelabel(callId: number | undefined, kind: 'sync' | 'async', name: string, method: string): void { + if (callId === undefined) { + return; + } + try { + relabelTurboModuleCallKind(callId, kind); + } catch (e) { + logger.warn(`[TurboModuleTracker] relabel failed for ${name}.${method}: ${String(e)}`); + } +} + function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index f385eebf0e..dde029b340 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -219,6 +219,20 @@ describe('turboModuleTracker', () => { expect(relabelTurboModuleCallKind(9999, 'async')).toBe(false); }); + it('rolls back the stack push if syncToScope throws (atomic push)', () => { + const failingScope = new Scope(); + jest.spyOn(failingScope, 'setContext').mockImplementation(() => { + throw new Error('native bridge boom'); + }); + + const before = getTurboModuleCallStack().length; + expect(() => + pushTurboModuleCall({ name: 'X', method: 'y', kind: 'sync', scope: failingScope }), + ).toThrow('native bridge boom'); + // Stack is unchanged — the failed push didn't leak a frame. + expect(getTurboModuleCallStack().length).toBe(before); + }); + it('assigns monotonically increasing call ids', () => { const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope }); const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index b41f060c11..8af003c34e 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -214,6 +214,53 @@ describe('wrapTurboModule', () => { ); }); + it('still calls the original method when the tracker push throws (native bridge error)', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + // Simulate a scope-sync hook that calls into a native bridge which throws. + jest.spyOn(scope, 'setContext').mockImplementation(() => { + throw new Error('NATIVE.setContext boom'); + }); + + const originalFn = jest.fn(() => 'real-result'); + const module = { doStuff: originalFn }; + + wrapTurboModule('Mod', module); + + // The user's call must still complete with the original return value + // — Sentry's instrumentation can never break the wrapped TurboModule. + const result = module.doStuff(); + + expect(result).toBe('real-result'); + expect(originalFn).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('push failed for Mod.doStuff')); + // And the failed push left no leaked frame on the tracker. + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('still calls the original method when the tracker pop throws', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + + const originalFn = jest.fn(() => 42); + const module = { doStuff: originalFn }; + + wrapTurboModule('Mod', module); + + // Make `setContext` throw only on the pop's restore/clear path. We do this + // by letting push succeed, then breaking setContext before the pop fires. + let setContextCalls = 0; + jest.spyOn(scope, 'setContext').mockImplementation(() => { + setContextCalls++; + if (setContextCalls > 1) { + throw new Error('clear boom'); + } + return scope; + }); + + expect(() => module.doStuff()).not.toThrow(); + expect(originalFn).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pop failed for Mod.doStuff')); + }); + it('does not leak a tracker frame when the result has a throwing `then` getter', () => { const trap = Object.defineProperty({}, 'then', { get() { From 5cf66a9c63a79d12a11100683764a27a846ad084 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 9 Jun 2026 10:43:38 +0200 Subject: [PATCH 8/9] Lint fixes --- packages/core/test/turbomodule/turboModuleTracker.test.ts | 6 +++--- packages/core/test/turbomodule/wrapTurboModule.test.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index dde029b340..ceb6e381f1 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -226,9 +226,9 @@ describe('turboModuleTracker', () => { }); const before = getTurboModuleCallStack().length; - expect(() => - pushTurboModuleCall({ name: 'X', method: 'y', kind: 'sync', scope: failingScope }), - ).toThrow('native bridge boom'); + expect(() => pushTurboModuleCall({ name: 'X', method: 'y', kind: 'sync', scope: failingScope })).toThrow( + 'native bridge boom', + ); // Stack is unchanged — the failed push didn't leak a frame. expect(getTurboModuleCallStack().length).toBe(before); }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 8af003c34e..37a4e6068a 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -209,9 +209,7 @@ describe('wrapTurboModule', () => { wrapTurboModule('Frozen', frozen); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("'Frozen' has methods but none could be wrapped"), - ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("'Frozen' has methods but none could be wrapped")); }); it('still calls the original method when the tracker push throws (native bridge error)', () => { From adb5c973f87f001ae9d7418b8427f8743d78d68b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 9 Jun 2026 11:11:07 +0200 Subject: [PATCH 9/9] chore(api-report): regenerate with workspace TypeScript 5.9.3 The committed API report had drifted from the format the workspace's pinned TypeScript 5.9.3 produces \u2014 earlier regenerations had resolved `tsc` via the system PATH (TS 4.9.5), which emits `number | undefined` where 5.9.3 emits `number`, single-quoted vs double-quoted string literals, and a different inlined form for `startIdleNavigationSpan`'s options. CI's `api-report:check` (which always uses the workspace's TS) caught the discrepancy. Regenerate against the correct TS version so CI passes. The actual public API surface is unchanged \u2014 only the textual representation in the report file. --- packages/core/etc/sentry-react-native.api.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 510bc4be6b..a4b02e65c0 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -145,7 +145,7 @@ export const appRegistryIntegration: () => Integration & { // // @public export const appStartIntegration: (input?: { - standalone?: boolean | undefined; + standalone?: boolean; }) => AppStartIntegration; export { Breadcrumb } @@ -335,7 +335,7 @@ export { FeedbackForm as FeedbackWidget } export const feedbackIntegration: (initOptions?: Partial & { buttonOptions?: FeedbackButtonProps; screenshotButtonOptions?: ScreenshotButtonProps; - colorScheme?: 'system' | 'light' | 'dark'; + colorScheme?: "system" | "light" | "dark"; themeLight?: Partial; themeDark?: Partial; enableShakeToReport?: boolean; @@ -746,15 +746,14 @@ export { Stacktrace } // @public export const stallTrackingIntegration: (input?: { - minimumStallThresholdMs?: number | undefined; + minimumStallThresholdMs?: number; }) => Integration; +// Warning: (ae-forgotten-export) The symbol "defaultIdleOptions" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial<{ - idleTimeout: number; - finalTimeout: number; -}> & { - isAppRestart?: boolean | undefined; +export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial & { + isAppRestart?: boolean; }) => Span | undefined; // @public