Skip to content
Merged
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 @@ -13,6 +13,7 @@
- Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930))
- Persist fatal JS error envelopes to disk on Android before the process exits ([#6937](https://github.com/getsentry/sentry-react-native/pull/6937))
- The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS.
- Support Expo Router 58 in `expoRouterIntegration`. Metro no longer warns about the removed `expo-router/build/global-state/router-store` module, and the integration creates navigation spans again ([#6923](https://github.com/getsentry/sentry-react-native/pull/6923))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Expo Router ≤55 there's no getRouteInfoFromState, so the route override is skipped and transaction names change from the templated path to the React Navigation route name. Users with dashboards or alerts on those names will notice, so it's worth calling out:

Suggested change
- Support Expo Router 58 in `expoRouterIntegration`. Metro no longer warns about the removed `expo-router/build/global-state/router-store` module, and the integration creates navigation spans again ([#6923](https://github.com/getsentry/sentry-react-native/pull/6923))
- Support Expo Router 58 in `expoRouterIntegration`. Metro no longer warns about the removed `expo-router/build/global-state/router-store` module, and the integration creates navigation spans again ([#6923](https://github.com/getsentry/sentry-react-native/pull/6923))
- On Expo Router 55 and older (Expo SDK 52–55), navigation transactions now use React Navigation route names (for example `[id]`) instead of templated paths (`/profile/[id]`). To keep full paths, set `useFullPathsForNavigationRoutes: true`.

Could you check in the ≤55 test that useFullPathsForNavigationRoutes actually produces the old templated path? If it doesn't, drop that sentence. And if you'd rather restore templated names on ≤55 here (e.g. build segments from the focused route names and reuse buildExpoRouterTemplatedPath), the sub-bullet isn't needed.


### Internal

Expand Down
2 changes: 2 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,7 @@ export interface ReactNativeWrapperOptions {
export const reactNavigationIntegration: (input?: Partial<ReactNavigationIntegrationOptions>) => Integration & {
registerNavigationContainer: (navigationContainerRef: unknown) => void;
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
_startIdleNavigationSpan: (event: UnsafeAction) => void;
options: ReactNavigationIntegrationOptions;
};

Expand Down Expand Up @@ -1337,6 +1338,7 @@ export function wrapTurboModule<T extends object>(name: string, module: T | null
//
// src/js/tracing/reactnativetracing.ts:93:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnavigation.ts:228:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnavigation.ts:233:3 - (ae-forgotten-export) The symbol "UnsafeAction" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/js/tracing/expoRouterIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { debug } from '@sentry/core';
import type { ExpoRouterStore, ExpoRouterUrlObject } from './expoRouterStore';
import type { RouteOverride } from './reactnavigation';

import { buildExpoRouterTemplatedPath, tryGetExpoRouterStore } from './expoRouterStore';
import {
buildExpoRouterTemplatedPath,
tryGetExpoRouterNavigationEvents,
tryGetExpoRouterStore,
} from './expoRouterStore';
import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation';

export { buildExpoRouterTemplatedPath };
Expand All @@ -15,6 +19,21 @@ export const INTEGRATION_NAME = 'ExpoRouter';
const POLL_INTERVAL_MS = 50;
const POLL_MAX_DURATION_MS = 5_000;

// Expo Router 58 emits `actionDispatched` for every reducer action, including internal
// ones such as `ROUTE_NAMES_CHANGED`, `PRELOAD`, and `SET_PARAMS`. Only user navigations
// start a navigation span; the rest would ship as empty "Route Change" transactions.
const NAVIGATION_ACTION_TYPES = new Set([
'NAVIGATE',
'PUSH',
'REPLACE',
'GO_BACK',
'POP',
'POP_TO',
'POP_TO_TOP',
'JUMP_TO',
'RESET',
]);

export type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationIntegration>[0];

/**
Expand All @@ -30,6 +49,7 @@ export type ExpoRouterIntegrationOptions = Parameters<typeof reactNavigationInte
*/
export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}): Integration => {
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let removeActionListener: (() => void) | undefined;

const afterAllSetup = (client: Client): void => {
const store = tryGetExpoRouterStore();
Expand Down Expand Up @@ -58,14 +78,26 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
}
reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store));
reactNavigation.registerNavigationContainer(navigationRef);
removeActionListener = tryGetExpoRouterNavigationEvents()?.addListener('actionDispatched', event => {
if (!event.actionType || !NAVIGATION_ACTION_TYPES.has(event.actionType)) {
return;
}
reactNavigation._startIdleNavigationSpan?.({
data: {
action: { type: event.actionType, payload: event.payload as object | undefined },
noop: false,
stack: undefined,
},
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
pollTimer = undefined;
return;
}

if (Date.now() - startedAt >= POLL_MAX_DURATION_MS) {
if (!navigationRef) {
debug.warn(
`${INTEGRATION_NAME} Found expo-router router-store but it does not expose a \`navigationRef\`. ` +
`${INTEGRATION_NAME} Found expo-router but it does not expose a \`navigationRef\`. ` +
`This likely means the installed expo-router version is incompatible with this integration.`,
);
} else {
Expand All @@ -85,6 +117,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {}
clearTimeout(pollTimer);
pollTimer = undefined;
}
removeActionListener?.();
removeActionListener = undefined;
});
};

Expand Down
87 changes: 81 additions & 6 deletions packages/core/src/js/tracing/expoRouterStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

export interface ExpoRouterNavigationRef {
current: unknown | null;
getRootState?: () => unknown;
}

export interface ExpoRouterUrlObject {
Expand All @@ -22,7 +23,7 @@ export interface ExpoRouterUrlObject {

export interface ExpoRouterStore {
navigationRef?: ExpoRouterNavigationRef;
getRouteInfo?: () => ExpoRouterUrlObject;
getRouteInfo?: () => ExpoRouterUrlObject | undefined;
}

export interface NormalizedExpoRouterRouteInfo {
Expand All @@ -42,17 +43,91 @@ export interface NormalizedExpoRouterRouteInfo {
segments?: string[];
}

export interface ExpoRouterActionDispatchedEvent {
actionType?: string;
payload?: unknown;
}

export interface ExpoRouterInternalNavigationEvents {
addListener: (
name: 'actionDispatched',
callback: (event: ExpoRouterActionDispatchedEvent) => void,
) => (() => void) | undefined;
}

/**
* Returns Expo Router's internal router store, or `null` if `expo-router` is
* not installed or the build does not expose the expected module path.
* Returns the navigation container ref and route info of Expo Router, or `null`
* if `expo-router` is not installed or does not expose the expected API.
*
* Expo Router 58 declares `exports`, so Metro warns about any deep path that is
* missing from it. Older versions have no `exports`, so a missing deep path
* there is a silent optional dependency.
*/
export function tryGetExpoRouterStore(): ExpoRouterStore | null {
let getNavigationContainerRef: (() => ExpoRouterNavigationRef | undefined) | undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const expoRouter = require('expo-router') as {
useNavigationContainerRef?: () => ExpoRouterNavigationRef | undefined;
};
// Not a React hook: it reads a module-level store, so it is safe to call outside render.
getNavigationContainerRef = expoRouter?.useNavigationContainerRef;
} catch {
return null;
}
if (!getNavigationContainerRef) {
return null;
}
const getRef = getNavigationContainerRef;
const readNavigationContainerRef = (): ExpoRouterNavigationRef | undefined => {
try {
return getRef();
} catch {
return undefined;
}
};
const getRouteInfoFromState = tryGetRouteInfoFromState();

return {
// Before Expo Router 58 the ref is undefined until the Root Layout renders, so read it on every access.
get navigationRef() {
return readNavigationContainerRef();
},
getRouteInfo: getRouteInfoFromState
? () => {
const navigationRef = readNavigationContainerRef();
const state = navigationRef?.current ? navigationRef.getRootState?.() : undefined;
return state ? getRouteInfoFromState(state) : undefined;
}
: undefined,
};
}

/** `getRouteInfoFromState` exists only in Expo Router 56 and later. */
function tryGetRouteInfoFromState(): ((state: unknown) => ExpoRouterUrlObject) | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-router/build/global-state/getRouteInfoFromState') as {
getRouteInfoFromState?: (state: unknown) => ExpoRouterUrlObject;
};
return mod?.getRouteInfoFromState;
} catch {
return undefined;
}
}

/**
* Returns the internal navigation event emitter of Expo Router 58 and later, or
* `null` for older versions. Expo Router 58 does not emit React Navigation's
* `__unsafe_action__` event, so this is the only signal of a dispatched action.
*/
export function tryGetExpoRouterNavigationEvents(): ExpoRouterInternalNavigationEvents | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-router/build/global-state/router-store') as {
store?: ExpoRouterStore;
const mod = require('expo-router/build/global-state/internalNavigationEvents') as {
internalNavigationEvents?: ExpoRouterInternalNavigationEvents;
};
return mod?.store ?? null;
return mod?.internalNavigationEvents ?? null;
Comment thread
alwx marked this conversation as resolved.
} catch {
return null;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ export const reactNavigationIntegration = ({
* supply the canonical route info on every state change. Use `undefined` to clear.
*/
_setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void;
/**
* @internal API: start a navigation span for a dispatched action, for navigators that do not
* emit `__unsafe_action__` (for example, Expo Router 58 and later).
*/
_startIdleNavigationSpan: (event: UnsafeAction) => void;
options: ReactNavigationIntegrationOptions;
} => {
let navigationContainer: NavigationContainer | undefined;
Expand Down Expand Up @@ -896,6 +901,7 @@ export const reactNavigationIntegration = ({
afterAllSetup,
registerNavigationContainer,
_setRouteOverrideProvider,
_startIdleNavigationSpan: event => startIdleNavigationSpan(event),
options: {
routeChangeTimeoutMs,
enableTimeToInitialDisplay,
Expand Down
Loading
Loading