Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
- 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))
Expand Down
95 changes: 95 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ export const feedbackIntegration: (initOptions?: Partial<FeedbackFormProps> & {
// @public
export function flush(): Promise<boolean>;

// @public (undocumented)
export type FontStyle = {
fontFamily: string;
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
};

export { functionToStringIntegration }

export { getActiveSpan }
Expand Down Expand Up @@ -491,6 +497,31 @@ export interface NativeLogEntry {
// @public
export const nativeReleaseIntegration: () => Integration;

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

// @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 }
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown>;
initialRouteName?: string;
};
getInitialURL?: () => string | null | undefined | Promise<string | null | undefined>;
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<Record<string, unknown>> | undefined) => void;
// (undocumented)
onUnhandledAction?: (action: Readonly<{
type: string;
payload?: object;
source?: string;
target?: string;
}>) => void;
// (undocumented)
theme?: NavigationTheme;
}

// @public
export function sentryTraceGesture<GestureT>(
label: string, gesture: GestureT): GestureT;
Expand Down
166 changes: 166 additions & 0 deletions packages/core/src/js/NavigationContainer.tsx
Original file line number Diff line number Diff line change
@@ -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';
};
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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<Record<string, unknown>> | 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<string, unknown>;
initialRouteName?: string;
};
getInitialURL?: () => string | null | undefined | Promise<string | null | undefined>;
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<string, unknown> | 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
* <Sentry.NavigationContainer>
* <Stack.Navigator>
* ...
* </Stack.Navigator>
* </Sentry.NavigationContainer>
* ```
*/
export const NavigationContainer = React.forwardRef<unknown, SentryNavigationContainerProps>((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} />;
});
2 changes: 2 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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();
});
});
Loading
Loading