Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,9 @@ export interface NativeLogEntry {
// @public
export const nativeReleaseIntegration: () => Integration;

// @public
export const NavigationContainer: React_2.ForwardRefExoticComponent<Omit<Record<string, unknown>, "ref"> & React_2.RefAttributes<unknown>>;

export { OpenAiClient }

export { OpenAiOptions }
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/js/NavigationContainer.tsx
Original file line number Diff line number Diff line change
@@ -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
* <Sentry.NavigationContainer>
* <Stack.Navigator>
* ...
* </Stack.Navigator>
* </Sentry.NavigationContainer>
* ```
*/
export const NavigationContainer = React.forwardRef<unknown, Record<string, unknown>>((props, forwardedRef) => {
const { onReady: userOnReady, ...restProps } = props;
const RealNavigationContainer = getNavigationContainerComponent();

const internalRef = React.useRef<unknown>(null);

const mergedRef = React.useCallback(
(instance: unknown) => {
internalRef.current = instance;
if (typeof forwardedRef === 'function') {
forwardedRef(instance);
} else if (forwardedRef != null) {
(forwardedRef as React.MutableRefObject<unknown>).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]);
Comment thread
antonis marked this conversation as resolved.

if (!RealNavigationContainer) {
if (!_warnedMissing) {
_warnedMissing = true;
debug.warn('[Sentry] NavigationContainer requires @react-navigation/native to be installed.');
}
return <>{restProps.children as React.ReactNode}</>;
}

return <RealNavigationContainer {...restProps} ref={mergedRef} onReady={onReady} />;
});
1 change: 1 addition & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/js/reactNavigationImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type * as React from 'react';

type NavigationContainerComponent = React.ComponentType<Record<string, unknown>>;

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;
}
39 changes: 39 additions & 0 deletions packages/core/test/NavigationContainer.missing.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NavigationContainer>
<Text>Fallback Content</Text>
</NavigationContainer>,
);
expect(getByText('Fallback Content')).toBeTruthy();
expect(mockDebugWarn).toHaveBeenCalled();
});
});
141 changes: 141 additions & 0 deletions packages/core/test/NavigationContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<View, Record<string, unknown>>((props, ref) => {
const { onReady, children, ...rest } = props;
React.useEffect(() => {
if (typeof onReady === 'function') {
(onReady as () => void)();
}
}, [onReady]);
return (
<View ref={ref as React.Ref<View>} testID="mock-navigation-container" {...rest}>
{children as React.ReactNode}
</View>
);
});

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(
<NavigationContainer>
<Text>Child Content</Text>
</NavigationContainer>,
);
expect(getByText('Child Content')).toBeTruthy();
});

it('calls registerNavigationContainer on ready', () => {
render(
<NavigationContainer>
<Text>App</Text>
</NavigationContainer>,
);
expect(mockRegisterNavigationContainer).toHaveBeenCalledTimes(1);
expect(mockRegisterNavigationContainer).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.anything() }),
);
});

it('forwards ref to the underlying NavigationContainer', () => {
const ref = React.createRef<unknown>();
render(
<NavigationContainer ref={ref}>
<Text>App</Text>
</NavigationContainer>,
);
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(
<NavigationContainer onReady={userOnReady}>
<Text>App</Text>
</NavigationContainer>,
);
expect(callOrder).toEqual(['sentry', 'user']);
});

it('chains user-provided onReady callback', () => {
const userOnReady = jest.fn();
render(
<NavigationContainer onReady={userOnReady}>
<Text>App</Text>
</NavigationContainer>,
);
expect(userOnReady).toHaveBeenCalledTimes(1);
expect(mockRegisterNavigationContainer).toHaveBeenCalledTimes(1);
});

it('warns and skips registration when client is not available', () => {
mockGetClient.mockReturnValue(undefined);
render(
<NavigationContainer>
<Text>App</Text>
</NavigationContainer>,
);
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(
<NavigationContainer>
<Text>App</Text>
</NavigationContainer>,
);
expect(mockRegisterNavigationContainer).not.toHaveBeenCalled();
expect(mockDebugLog).toHaveBeenCalledWith(expect.stringContaining('reactNavigationIntegration is not registered'));
});

it('passes through all props to NavigationContainer', () => {
const { getByTestId } = render(
<NavigationContainer accessibilityLabel="nav">
<Text>App</Text>
</NavigationContainer>,
);
const container = getByTestId('mock-navigation-container');
expect(container.props.accessibilityLabel).toBe('nav');
});
});
14 changes: 2 additions & 12 deletions samples/react-native-macos/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down Expand Up @@ -172,14 +168,8 @@ const TabTwoStack = Sentry.withProfiler(
);

function BottomTabs() {
const navigation = React.useRef<NavigationContainerRef<{}>>(null);

return (
<NavigationContainer
ref={navigation}
onReady={() => {
reactNavigationIntegration.registerNavigationContainer(navigation);
}}>
<Sentry.NavigationContainer>
<Tab.Navigator
screenOptions={{
headerShown: false,
Expand Down Expand Up @@ -232,7 +222,7 @@ function BottomTabs() {
/>
</Tab.Navigator>
<RunningIndicator />
</NavigationContainer>
</Sentry.NavigationContainer>
);
}

Expand Down
16 changes: 3 additions & 13 deletions samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,14 +230,8 @@ function BottomTabsNavigator() {
}

function RootNavigationContainer() {
const navigation = React.useRef<NavigationContainerRef<{}>>(null);

return (
<NavigationContainer
ref={navigation}
onReady={() => {
reactNavigationIntegration.registerNavigationContainer(navigation);
}}>
<Sentry.NavigationContainer>
<StackNavigator.Navigator
screenOptions={{
headerShown: false,
Expand All @@ -261,7 +251,7 @@ function RootNavigationContainer() {
options={{ headerShown: true }}
/>
</StackNavigator.Navigator>
</NavigationContainer>
</Sentry.NavigationContainer>
);
}

Expand Down
Loading