diff --git a/CHANGELOG.md b/CHANGELOG.md index 506ed31ad1..45dde5f777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add `Sentry.NavigationContainer` drop-in wrapper for React Navigation ([#6199](https://github.com/getsentry/sentry-react-native/pull/6199)) - Opt-in: consume sentry-cocoa via Swift Package Manager. Set `SENTRY_USE_SPM=1` before `pod install` to pull `Sentry` from sentry-cocoa's SPM package as a binary xcframework instead of the CocoaPods source build ([#6182](https://github.com/getsentry/sentry-react-native/pull/6182)) - 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)) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index cb69dab67e..5c1ca742ea 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -343,6 +343,12 @@ export const feedbackIntegration: (initOptions?: Partial & { // @public export function flush(): Promise; +// @public (undocumented) +export type FontStyle = { + fontFamily: string; + fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; +}; + export { functionToStringIntegration } export { getActiveSpan } @@ -491,6 +497,31 @@ export interface NativeLogEntry { // @public export const nativeReleaseIntegration: () => Integration; +// @public +export const NavigationContainer: React_2.ForwardRefExoticComponent & React_2.RefAttributes>; + +// @public +export interface NavigationTheme { + // (undocumented) + colors: { + primary: string; + background: string; + card: string; + text: string; + border: string; + notification: string; + }; + // (undocumented) + dark: boolean; + // (undocumented) + fonts: { + regular: FontStyle; + medium: FontStyle; + bold: FontStyle; + heavy: FontStyle; + }; +} + export { OpenAiClient } export { OpenAiOptions } @@ -586,6 +617,70 @@ export const sdkInfoIntegration: () => Integration; export { SendFeedbackParams } +// @public +export interface SentryNavigationContainerProps { + // (undocumented) + [key: string]: unknown; + // (undocumented) + children: React_2.ReactNode; + // (undocumented) + direction?: 'ltr' | 'rtl'; + // (undocumented) + documentTitle?: { + enabled?: boolean; + formatter?: (options: Record | undefined, route: { + key: string; + name: string; + params?: object; + } | undefined) => string; + }; + // (undocumented) + fallback?: React_2.ReactNode; + // (undocumented) + initialState?: { + index?: number; + key?: string; + routeNames?: string[]; + routes: Array<{ + key?: string; + name: string; + params?: object; + state?: object; + }>; + stale?: true; + type?: string; + }; + // (undocumented) + linking?: { + enabled?: boolean; + prefixes: string[]; + filter?: (url: string) => boolean; + config?: { + path?: string; + screens: Record; + initialRouteName?: string; + }; + getInitialURL?: () => string | null | undefined | Promise; + subscribe?: (listener: (url: string) => void) => undefined | void | (() => void); + getStateFromPath?: (path: string, options?: object) => object | undefined; + getPathFromState?: (state: object, options?: object) => string; + getActionFromState?: (state: object, options?: object) => object | undefined; + }; + // (undocumented) + onReady?: () => void; + // (undocumented) + onStateChange?: (state: Readonly> | undefined) => void; + // (undocumented) + onUnhandledAction?: (action: Readonly<{ + type: string; + payload?: object; + source?: string; + target?: string; + }>) => void; + // (undocumented) + theme?: NavigationTheme; +} + // @public export function sentryTraceGesture( label: string, gesture: GestureT): GestureT; diff --git a/packages/core/src/js/NavigationContainer.tsx b/packages/core/src/js/NavigationContainer.tsx new file mode 100644 index 0000000000..e4c0be5e2a --- /dev/null +++ b/packages/core/src/js/NavigationContainer.tsx @@ -0,0 +1,166 @@ +import { debug, getClient } from '@sentry/core'; +import * as React from 'react'; + +import { getNavigationContainerComponent } from './reactNavigationImport'; +import { getReactNavigationIntegration } from './tracing/reactnavigation'; + +export type FontStyle = { + fontFamily: string; + fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; +}; + +/** + * Mirrors the `Theme` type from `@react-navigation/native`. + */ +export interface NavigationTheme { + dark: boolean; + colors: { + primary: string; + background: string; + card: string; + text: string; + border: string; + notification: string; + }; + fonts: { + regular: FontStyle; + medium: FontStyle; + bold: FontStyle; + heavy: FontStyle; + }; +} + +/** + * Props accepted by `Sentry.NavigationContainer`. + * + * Mirrors the props of `NavigationContainer` from `@react-navigation/native` + * so that users get autocomplete without requiring a compile-time dependency + * on the library. + */ +export interface SentryNavigationContainerProps { + children: React.ReactNode; + initialState?: { + index?: number; + key?: string; + routeNames?: string[]; + routes: Array<{ + key?: string; + name: string; + params?: object; + state?: object; + }>; + stale?: true; + type?: string; + }; + onStateChange?: (state: Readonly> | undefined) => void; + onReady?: () => void; + onUnhandledAction?: (action: Readonly<{ type: string; payload?: object; source?: string; target?: string }>) => void; + theme?: NavigationTheme; + direction?: 'ltr' | 'rtl'; + linking?: { + enabled?: boolean; + prefixes: string[]; + filter?: (url: string) => boolean; + config?: { + path?: string; + screens: Record; + initialRouteName?: string; + }; + getInitialURL?: () => string | null | undefined | Promise; + subscribe?: (listener: (url: string) => void) => undefined | void | (() => void); + getStateFromPath?: (path: string, options?: object) => object | undefined; + getPathFromState?: (state: object, options?: object) => string; + getActionFromState?: (state: object, options?: object) => object | undefined; + }; + fallback?: React.ReactNode; + documentTitle?: { + enabled?: boolean; + formatter?: ( + options: Record | undefined, + route: { key: string; name: string; params?: object } | undefined, + ) => string; + }; + [key: string]: unknown; +} + +let _warnedMissing = false; +let _warnedNoClient = false; +let _warnedNoIntegration = false; + +/** + * Drop-in replacement for `NavigationContainer` from `@react-navigation/native` + * that automatically wires up Sentry's `reactNavigationIntegration`. + * + * Sentry registers the navigation container before the user-provided `onReady` + * callback fires, so navigation spans are captured from the first route. + * + * If `@react-navigation/native` is not installed, children are rendered directly + * and `onReady` is not called. + * + * @example + * ```jsx + * + * + * ... + * + * + * ``` + */ +export const NavigationContainer = React.forwardRef((props, forwardedRef) => { + const { onReady: userOnReady, ...restProps } = props; + const RealNavigationContainer = getNavigationContainerComponent(); + + const internalRef = React.useRef(null); + + const mergedRef = React.useCallback( + (instance: unknown) => { + internalRef.current = instance; + if (typeof forwardedRef === 'function') { + forwardedRef(instance); + } else if (forwardedRef != null) { + (forwardedRef as React.MutableRefObject).current = instance; + } + }, + [forwardedRef], + ); + + const onReady = React.useCallback(() => { + try { + const client = getClient(); + if (!client) { + if (!_warnedNoClient) { + _warnedNoClient = true; + debug.warn( + '[Sentry] NavigationContainer: Sentry is not initialized. Call Sentry.init() before mounting NavigationContainer.', + ); + } + } else { + const integration = getReactNavigationIntegration(client); + if (integration) { + integration.registerNavigationContainer(internalRef); + } else if (!_warnedNoIntegration) { + _warnedNoIntegration = true; + debug.log( + '[Sentry] NavigationContainer: reactNavigationIntegration is not registered. Navigation spans will not be captured.', + ); + } + } + } catch { + // SDK failures must never break the host app + } + + if (typeof userOnReady === 'function') { + (userOnReady as () => void)(); + } + }, [userOnReady]); + + if (!RealNavigationContainer) { + if (!_warnedMissing) { + _warnedMissing = true; + debug.warn('[Sentry] NavigationContainer requires @react-navigation/native to be installed.'); + } + return <>{restProps.children as React.ReactNode}; + } + + return ; +}); diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 46d0d6a6f6..17feb632cb 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -111,6 +111,8 @@ export { resumeAppHangTracking, } from './sdk'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; +export { NavigationContainer } from './NavigationContainer'; +export type { FontStyle, NavigationTheme, SentryNavigationContainerProps } from './NavigationContainer'; export { GlobalErrorBoundary, withGlobalErrorBoundary } from './GlobalErrorBoundary'; export type { GlobalErrorBoundaryProps } from './GlobalErrorBoundary'; diff --git a/packages/core/src/js/reactNavigationImport.ts b/packages/core/src/js/reactNavigationImport.ts new file mode 100644 index 0000000000..23eb707153 --- /dev/null +++ b/packages/core/src/js/reactNavigationImport.ts @@ -0,0 +1,25 @@ +import type * as React from 'react'; + +type NavigationContainerComponent = React.ComponentType>; + +let _cached: NavigationContainerComponent | null | undefined; + +/** + * @returns NavigationContainer from @react-navigation/native or null if not installed. + * The result is cached after the first call. + */ +export function getNavigationContainerComponent(): NavigationContainerComponent | null { + if (_cached !== undefined) { + return _cached; + } + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('@react-navigation/native') as { + NavigationContainer?: NavigationContainerComponent; + }; + _cached = mod?.NavigationContainer ?? null; + } catch { + _cached = null; + } + return _cached; +} diff --git a/packages/core/test/NavigationContainer.missing.test.tsx b/packages/core/test/NavigationContainer.missing.test.tsx new file mode 100644 index 0000000000..0d12ef6594 --- /dev/null +++ b/packages/core/test/NavigationContainer.missing.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; + +import { NavigationContainer } from '../src/js/NavigationContainer'; + +const mockDebugWarn = jest.fn(); + +jest.mock('@sentry/core', () => ({ + getClient: () => undefined, + debug: { + get log() { + return jest.fn(); + }, + get warn() { + return mockDebugWarn; + }, + }, +})); + +jest.mock('../src/js/tracing/reactnavigation', () => ({ + getReactNavigationIntegration: () => undefined, +})); + +jest.mock('../src/js/reactNavigationImport', () => ({ + getNavigationContainerComponent: () => null, +})); + +describe('NavigationContainer without @react-navigation/native', () => { + it('renders children directly and warns', () => { + const { getByText } = render( + + Fallback Content + , + ); + expect(getByText('Fallback Content')).toBeTruthy(); + expect(mockDebugWarn).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/NavigationContainer.test.tsx b/packages/core/test/NavigationContainer.test.tsx new file mode 100644 index 0000000000..d5f358459e --- /dev/null +++ b/packages/core/test/NavigationContainer.test.tsx @@ -0,0 +1,141 @@ +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import { NavigationContainer } from '../src/js/NavigationContainer'; + +const mockRegisterNavigationContainer = jest.fn(); +const mockGetClient = jest.fn(); +const mockDebugLog = jest.fn(); +const mockDebugWarn = jest.fn(); + +jest.mock('@sentry/core', () => ({ + getClient: (...args: unknown[]) => mockGetClient(...args), + debug: { + get log() { + return mockDebugLog; + }, + get warn() { + return mockDebugWarn; + }, + }, +})); + +const mockGetReactNavigationIntegration = jest.fn(); +jest.mock('../src/js/tracing/reactnavigation', () => ({ + getReactNavigationIntegration: (...args: unknown[]) => mockGetReactNavigationIntegration(...args), +})); + +const MockNavigationContainerComponent = React.forwardRef>((props, ref) => { + const { onReady, children, ...rest } = props; + React.useEffect(() => { + if (typeof onReady === 'function') { + (onReady as () => void)(); + } + }, [onReady]); + return ( + } testID="mock-navigation-container" {...rest}> + {children as React.ReactNode} + + ); +}); + +jest.mock('../src/js/reactNavigationImport', () => ({ + getNavigationContainerComponent: () => MockNavigationContainerComponent, +})); + +describe('NavigationContainer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetClient.mockReturnValue({ getIntegrationByName: jest.fn() }); + mockGetReactNavigationIntegration.mockReturnValue({ + registerNavigationContainer: mockRegisterNavigationContainer, + }); + }); + + it('renders children through to the underlying NavigationContainer', () => { + const { getByText } = render( + + Child Content + , + ); + expect(getByText('Child Content')).toBeTruthy(); + }); + + it('calls registerNavigationContainer on ready', () => { + render( + + App + , + ); + expect(mockRegisterNavigationContainer).toHaveBeenCalledTimes(1); + expect(mockRegisterNavigationContainer).toHaveBeenCalledWith( + expect.objectContaining({ current: expect.anything() }), + ); + }); + + it('forwards ref to the underlying NavigationContainer', () => { + const ref = React.createRef(); + render( + + App + , + ); + expect(ref.current).toBeTruthy(); + }); + + it('calls registerNavigationContainer before user onReady', () => { + const callOrder: string[] = []; + mockRegisterNavigationContainer.mockImplementation(() => callOrder.push('sentry')); + const userOnReady = jest.fn(() => callOrder.push('user')); + render( + + App + , + ); + expect(callOrder).toEqual(['sentry', 'user']); + }); + + it('chains user-provided onReady callback', () => { + const userOnReady = jest.fn(); + render( + + App + , + ); + expect(userOnReady).toHaveBeenCalledTimes(1); + expect(mockRegisterNavigationContainer).toHaveBeenCalledTimes(1); + }); + + it('warns and skips registration when client is not available', () => { + mockGetClient.mockReturnValue(undefined); + render( + + App + , + ); + expect(mockRegisterNavigationContainer).not.toHaveBeenCalled(); + expect(mockDebugWarn).toHaveBeenCalledWith(expect.stringContaining('Sentry is not initialized')); + }); + + it('logs when client exists but reactNavigationIntegration is not registered', () => { + mockGetReactNavigationIntegration.mockReturnValue(undefined); + render( + + App + , + ); + expect(mockRegisterNavigationContainer).not.toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith(expect.stringContaining('reactNavigationIntegration is not registered')); + }); + + it('passes through all props to NavigationContainer', () => { + const { getByTestId } = render( + + App + , + ); + const container = getByTestId('mock-navigation-container'); + expect(container.props.accessibilityLabel).toBe('nav'); + }); +}); diff --git a/samples/react-native-macos/src/App.tsx b/samples/react-native-macos/src/App.tsx index a43fd6793b..0e43c01c29 100644 --- a/samples/react-native-macos/src/App.tsx +++ b/samples/react-native-macos/src/App.tsx @@ -1,8 +1,4 @@ import React from 'react'; -import { - NavigationContainer, - NavigationContainerRef, -} from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import Animated, { @@ -172,14 +168,8 @@ const TabTwoStack = Sentry.withProfiler( ); function BottomTabs() { - const navigation = React.useRef>(null); - return ( - { - reactNavigationIntegration.registerNavigationContainer(navigation); - }}> + - + ); } diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 5501974645..e6e6971ea6 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -2,11 +2,7 @@ import React from 'react'; import { Ionicons } from '@react-native-vector-icons/ionicons'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { - NavigationContainer, - NavigationContainerRef, - TypedNavigator, -} from '@react-navigation/native'; +import { TypedNavigator } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createStackNavigator } from '@react-navigation/stack'; import * as Sentry from '@sentry/react-native'; @@ -234,14 +230,8 @@ function BottomTabsNavigator() { } function RootNavigationContainer() { - const navigation = React.useRef>(null); - return ( - { - reactNavigationIntegration.registerNavigationContainer(navigation); - }}> + - + ); }