diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b688ba123..60052efe00 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)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) ## 8.12.0 diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 806e67c2d7..662fbd9939 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -491,6 +491,9 @@ export interface NativeLogEntry { // @public export const nativeReleaseIntegration: () => Integration; +// @public +export const NavigationContainer: React_2.ForwardRefExoticComponent, "ref"> & React_2.RefAttributes>; + export { OpenAiClient } export { OpenAiOptions } diff --git a/packages/core/src/js/NavigationContainer.tsx b/packages/core/src/js/NavigationContainer.tsx new file mode 100644 index 0000000000..31671edb2d --- /dev/null +++ b/packages/core/src/js/NavigationContainer.tsx @@ -0,0 +1,87 @@ +import { debug, getClient } from '@sentry/core'; +import * as React from 'react'; + +import { getNavigationContainerComponent } from './reactNavigationImport'; +import { getReactNavigationIntegration } from './tracing/reactnavigation'; + +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..f7ce5f2825 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -111,6 +111,7 @@ export { resumeAppHangTracking, } from './sdk'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; +export { NavigationContainer } 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); - }}> + - + ); }