From 77aadadbe08f83d97878e38f831ea4064e365c80 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 13:58:27 +0200 Subject: [PATCH 1/5] feat(core): Name navigation spans using dispatched action payload When `useDispatchedActionData` is enabled, extract the route name from the dispatched action's `payload.name` (available for NAVIGATE, PUSH, REPLACE, JUMP_TO actions) and use it to name the navigation span immediately at dispatch time instead of the generic 'Route Change'. Actions without a known route name in the payload (GO_BACK, POP, POP_TO_TOP, RESET) no longer create navigation spans when `useDispatchedActionData` is enabled, reducing noise. The span name is still updated with the final route name from the router's state change, unless `beforeStartSpan` has customized it. Resolves #4429 --- .../core/src/js/tracing/reactnavigation.ts | 42 +++++- .../core/test/tracing/reactnavigation.test.ts | 134 ++++++++++++++++++ .../core/test/tracing/reactnavigationutils.ts | 56 +++++++- 3 files changed, 224 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index e717df4900..31237f5946 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -198,6 +198,7 @@ export const reactNavigationIntegration = ({ let latestRoute: NavigationRoute | undefined; let latestNavigationSpan: Span | undefined; + let latestNavigationSpanNameCustomized: boolean = false; let navigationProcessingSpan: Span | undefined; let initialStateHandled: boolean = false; @@ -376,18 +377,31 @@ export const reactNavigationIntegration = ({ return; } + // Extract route name from dispatch action payload when available + const dispatchedRouteName = getRouteNameFromAction(event); + if (useDispatchedActionData && !dispatchedRouteName && !isAppRestart) { + debug.log(`${INTEGRATION_NAME} Navigation action has no route name in payload, not starting navigation span.`); + return; + } + if (latestNavigationSpan) { debug.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); _discardLatestTransaction(); clearStateChangeTimeout(); } - latestNavigationSpan = startGenericIdleNavigationSpan( - tracing?.options.beforeStartSpan - ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) - : getDefaultIdleNavigationSpanOptions(), - { ...idleSpanOptions, isAppRestart }, - ); + const spanOptions = getDefaultIdleNavigationSpanOptions(); + if (dispatchedRouteName) { + spanOptions.name = dispatchedRouteName; + } + + const originalName = spanOptions.name; + const finalSpanOptions = tracing?.options.beforeStartSpan + ? tracing.options.beforeStartSpan(spanOptions) + : spanOptions; + latestNavigationSpanNameCustomized = finalSpanOptions.name !== originalName; + + latestNavigationSpan = startGenericIdleNavigationSpan(finalSpanOptions, { ...idleSpanOptions, isAppRestart }); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType); if (ignoreEmptyBackNavigationTransactions) { @@ -472,7 +486,7 @@ export const reactNavigationIntegration = ({ navigationProcessingSpan?.end(stateChangedTimestamp); navigationProcessingSpan = undefined; - if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { + if (!latestNavigationSpanNameCustomized) { latestNavigationSpan.updateName(routeName); } const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false; @@ -578,6 +592,20 @@ interface NavigationContainer { getState: () => NavigationState | undefined; } +/** + * Extracts the route name from a React Navigation dispatch action payload. + * + * Actions like NAVIGATE, PUSH, REPLACE, JUMP_TO carry the target route name + * in `action.payload.name`. Actions like GO_BACK, POP, POP_TO_TOP do not. + */ +function getRouteNameFromAction(event: UnsafeAction | undefined): string | undefined { + const payload = event?.data?.action?.payload; + if (payload && typeof payload === 'object' && 'name' in payload && typeof payload.name === 'string') { + return payload.name; + } + return undefined; +} + /** * Returns React Navigation integration of the given client. */ diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 23bb2fb9d6..ecd4edb9aa 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -943,6 +943,140 @@ describe('ReactNavigationInstrumentation', () => { }, ); + describe('useDispatchedActionData names spans from action payload', () => { + beforeEach(async () => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); // Flush the initial navigation span + client.event = undefined; + }); + + test('NAVIGATE action with payload.name sets the span name', async () => { + mockNavigation.navigateToNewScreenWithPayload(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('New Screen'); + }); + + test('span name is updated with final route from state change when it differs from payload', async () => { + mockNavigation.navigateToScreenWithMismatchedPayload(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('ScreenB'); + }); + + test('GO_BACK action (no payload.name) does not create a navigation span', async () => { + mockNavigation.emitGoBackWithStateChange(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event).toBeUndefined(); + }); + + test.each(['GO_BACK', 'POP', 'POP_TO_TOP', 'RESET'])( + '%s action does not create a navigation span', + async actionType => { + mockNavigation.emitWithStateChange({ + data: { + action: { + type: actionType, + }, + noop: false, + stack: undefined, + }, + }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toBeUndefined(); + }, + ); + + test('PUSH action with payload.name creates a named span', async () => { + mockNavigation.emitWithStateChange( + { + data: { + action: { + type: 'PUSH', + payload: { name: 'PushedScreen' }, + }, + noop: false, + stack: undefined, + }, + }, + { key: 'pushed_screen', name: 'PushedScreen' }, + ); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('PushedScreen'); + }); + + test('REPLACE action with payload.name creates a named span', async () => { + mockNavigation.emitWithStateChange( + { + data: { + action: { + type: 'REPLACE', + payload: { name: 'ReplacedScreen' }, + }, + noop: false, + stack: undefined, + }, + }, + { key: 'replaced_screen', name: 'ReplacedScreen' }, + ); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('ReplacedScreen'); + }); + + test('JUMP_TO action with payload.name creates a named span', async () => { + mockNavigation.emitWithStateChange( + { + data: { + action: { + type: 'JUMP_TO', + payload: { name: 'TabScreen' }, + }, + noop: false, + stack: undefined, + }, + }, + { key: 'tab_screen', name: 'TabScreen' }, + ); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('TabScreen'); + }); + }); + + describe('useDispatchedActionData disabled (default) still uses generic span name', () => { + beforeEach(async () => { + setupTestClient({ useDispatchedActionData: false }); + await jest.runOnlyPendingTimers(); // Flush the initial navigation span + client.event = undefined; + }); + + test('GO_BACK action still creates a navigation span', async () => { + mockNavigation.emitGoBackWithStateChange(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.transaction).toBe('Previous Screen'); + }); + }); + describe('setCurrentRoute', () => { let mockSetCurrentRoute: jest.Mock; diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index d5cafd7de4..ca9a905f32 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -11,6 +11,29 @@ const navigationAction: UnsafeAction = { }, }; +function navigationActionWithPayload(name: string): UnsafeAction { + return { + data: { + action: { + type: 'NAVIGATE', + payload: { name }, + }, + noop: false, + stack: undefined, + }, + }; +} + +const goBackAction: UnsafeAction = { + data: { + action: { + type: 'GO_BACK', + }, + noop: false, + stack: undefined, + }, +}; + export function createMockNavigationAndAttachTo(sut: ReturnType) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { @@ -62,8 +85,11 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { mockedNavigationContained.listeners['__unsafe_action__'](action); }, - emitWithStateChange: (action: UnsafeAction) => { + emitWithStateChange: (action: UnsafeAction, route?: NavigationRoute) => { mockedNavigationContained.listeners['__unsafe_action__'](action); + if (route) { + mockedNavigationContained.currentRoute = route; + } mockedNavigationContained.listeners['state']({ // this object is not used by the instrumentation }); @@ -95,6 +121,34 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { + mockedNavigationContained.listeners['__unsafe_action__'](navigationActionWithPayload('New Screen')); + mockedNavigationContained.currentRoute = { + key: 'new_screen', + name: 'New Screen', + }; + mockedNavigationContained.listeners['state']({}); + }, + navigateToScreenWithMismatchedPayload: () => { + // Dispatch says "ScreenA" but router resolves to "ScreenB" (e.g. nested navigation) + mockedNavigationContained.listeners['__unsafe_action__'](navigationActionWithPayload('ScreenA')); + mockedNavigationContained.currentRoute = { + key: 'screen_b', + name: 'ScreenB', + }; + mockedNavigationContained.listeners['state']({}); + }, + emitGoBackWithStateChange: () => { + mockedNavigationContained.listeners['__unsafe_action__'](goBackAction); + mockedNavigationContained.currentRoute = { + key: 'previous_screen', + name: 'Previous Screen', + }; + mockedNavigationContained.listeners['state']({}); + }, + emitGoBackWithoutStateChange: () => { + mockedNavigationContained.listeners['__unsafe_action__'](goBackAction); + }, emitNavigationWithUndefinedRoute: () => { mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = undefined as any; From 3ca05a546c3cf5c4817b3c43495d413725f82987 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 14:40:09 +0200 Subject: [PATCH 2/5] docs: Add changelog entry for navigation span naming (#5982) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39958f463d..1cc577010e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947)) - Add `attachAllThreads` option to attach full stack traces for all threads to captured events on iOS ([#5960](https://github.com/getsentry/sentry-react-native/issues/5960)) +- Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982)) ### Fixes From dcfe90c74cdcb2ae63adf7be7f0322c0e779a291 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 14:57:46 +0200 Subject: [PATCH 3/5] fix(core): Gate dispatched route name extraction behind useDispatchedActionData Ensure getRouteNameFromAction is only called when useDispatchedActionData is enabled. Previously, the route name was extracted from the action payload even when the feature was disabled, which could change the span name and beforeStartSpan input for users who hadn't opted in. --- packages/core/src/js/tracing/reactnavigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 31237f5946..4cccc0fc57 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -378,7 +378,7 @@ export const reactNavigationIntegration = ({ } // Extract route name from dispatch action payload when available - const dispatchedRouteName = getRouteNameFromAction(event); + const dispatchedRouteName = useDispatchedActionData ? getRouteNameFromAction(event) : undefined; if (useDispatchedActionData && !dispatchedRouteName && !isAppRestart) { debug.log(`${INTEGRATION_NAME} Navigation action has no route name in payload, not starting navigation span.`); return; From d39dbda517da29c5ebfd929c7a8ac1f60dc61eb8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 16:16:01 +0200 Subject: [PATCH 4/5] fix(core): Fix initial span and orphaned span handling with useDispatchedActionData Two fixes based on PR review feedback: 1. Allow setup/programmatic calls (no event) to create the initial navigation span even when useDispatchedActionData is enabled. The early return for actions without payload.name now checks that an event exists, so afterAllSetup and registerNavigationContainer still work correctly. 2. Pass the actual span name to ignoreEmptyRouteChangeTransactions instead of the hardcoded default. This ensures orphaned spans created with a dispatched route name are still discarded when the state listener is never called. --- .../core/src/js/tracing/reactnavigation.ts | 10 ++-- .../core/test/tracing/reactnavigation.test.ts | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 4cccc0fc57..6af6a245d9 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -379,7 +379,7 @@ export const reactNavigationIntegration = ({ // Extract route name from dispatch action payload when available const dispatchedRouteName = useDispatchedActionData ? getRouteNameFromAction(event) : undefined; - if (useDispatchedActionData && !dispatchedRouteName && !isAppRestart) { + if (useDispatchedActionData && event && !dispatchedRouteName && !isAppRestart) { debug.log(`${INTEGRATION_NAME} Navigation action has no route name in payload, not starting navigation span.`); return; } @@ -409,12 +409,8 @@ export const reactNavigationIntegration = ({ } // Always discard transactions that never receive route information const spanToCheck = latestNavigationSpan; - ignoreEmptyRouteChangeTransactions( - getClient(), - spanToCheck, - DEFAULT_NAVIGATION_SPAN_NAME, - () => latestNavigationSpan === spanToCheck, - ); + const spanName = finalSpanOptions.name ?? DEFAULT_NAVIGATION_SPAN_NAME; + ignoreEmptyRouteChangeTransactions(getClient(), spanToCheck, spanName, () => latestNavigationSpan === spanToCheck); if (enableTimeToInitialDisplay && latestNavigationSpan) { NATIVE.setActiveSpanId(latestNavigationSpan.spanContext().spanId); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index ecd4edb9aa..ec1095d2e0 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1058,6 +1058,52 @@ describe('ReactNavigationInstrumentation', () => { expect(client.event?.transaction).toBe('TabScreen'); }); + + test('cancelled navigation with dispatched route name is discarded', async () => { + mockNavigation.emitWithoutStateChange({ + data: { + action: { + type: 'NAVIGATE', + payload: { name: 'OrphanedScreen' }, + }, + noop: false, + stack: undefined, + }, + }); + jest.runOnlyPendingTimers(); // Trigger the timeout + + await client.flush(); + + expect(client.event).toBeUndefined(); + }); + + test('initial navigation span is created during setup', async () => { + // Reset and create fresh client to test initial span + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useDispatchedActionData: true, + }); + const freshMockNavigation = createMockNavigationAndAttachTo(rNavigation); + + const rnTracing = reactNativeTracingIntegration(); + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, rnTracing], + enableAppStartTracking: false, + }); + const freshClient = new TestClient(options); + setCurrentClient(freshClient); + freshClient.init(); + + jest.runOnlyPendingTimers(); // Flush the initial navigation span + + await freshClient.flush(); + + // Initial span should be created with 'Initial Screen' from the mock container + expect(freshClient.event?.transaction).toBe('Initial Screen'); + }); }); describe('useDispatchedActionData disabled (default) still uses generic span name', () => { From 461ba9b7d86a9d2bf893f7365bc169d15f1aa9e6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 17:03:16 +0200 Subject: [PATCH 5/5] chore(test): Remove unused emitGoBackWithoutStateChange helper --- packages/core/test/tracing/reactnavigationutils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index ca9a905f32..93ee6e3dab 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -146,9 +146,6 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { - mockedNavigationContained.listeners['__unsafe_action__'](goBackAction); - }, emitNavigationWithUndefinedRoute: () => { mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = undefined as any;