diff --git a/CHANGELOG.md b/CHANGELOG.md index 046a2c08a9..e59af82c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ ``` - Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788)) +### Fixes + +- Defer initial navigation span creation until navigation container is registered ([#5789](https://github.com/getsentry/sentry-react-native/pull/5789)) + ## 8.3.0 ### Features diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index aa9f549bb8..9296be1d3d 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -195,6 +195,7 @@ export const reactNavigationIntegration = ({ let navigationProcessingSpan: Span | undefined; let initialStateHandled: boolean = false; + let isSetupComplete: boolean = false; let stateChangeTimeout: ReturnType | undefined; let recentRouteKeys: string[] = []; @@ -233,14 +234,15 @@ export const reactNavigationIntegration = ({ } }); - startIdleNavigationSpan(); + isSetupComplete = true; if (!navigationContainer) { // This is expected as navigation container is registered after the root component is mounted. return undefined; } - // Navigation container already registered, just populate with route state + // Navigation container already registered, create and populate initial span + startIdleNavigationSpan(); updateLatestNavigationSpanWithCurrentRoute(); initialStateHandled = true; }; @@ -282,8 +284,13 @@ export const reactNavigationIntegration = ({ } if (!latestNavigationSpan) { - debug.log(`${INTEGRATION_NAME} Navigation container registered, but integration has not been setup yet.`); - return undefined; + if (!isSetupComplete) { + debug.log( + `${INTEGRATION_NAME} Navigation container registered before integration setup. Initial span will be created when setup completes.`, + ); + return undefined; + } + startIdleNavigationSpan(); } // Navigation Container is registered after the first navigation diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index fc478b3f99..d5a4baa541 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -597,6 +597,176 @@ describe('ReactNavigationInstrumentation', () => { }); }); + describe('deferred initial span creation', () => { + test('initial span is created in registerNavigationContainer when called after afterAllSetup', async () => { + const instrumentation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + }); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [instrumentation], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // No span created yet — no navigation container registered + expect(getActiveSpan()).toBeUndefined(); + + // Register navigation container after init (production flow) + const mockNavigationContainer = new MockNavigationContainer(); + instrumentation.registerNavigationContainer({ current: mockNavigationContainer }); + + const span = getActiveSpan(); + expect(span).toBeDefined(); + expect(spanToJSON(span!).description).toBe('Route'); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Route', + }), + ); + }); + + test('initial span is not discarded by idle timeout when container registers after init', async () => { + const instrumentation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + }); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [instrumentation], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Simulate a delay before container registration (e.g. auth gate, slow render) + jest.advanceTimersByTime(500); + expect(getActiveSpan()).toBeUndefined(); + + // Register after 500ms — should still create the span successfully + const mockNavigationContainer = new MockNavigationContainer(); + instrumentation.registerNavigationContainer({ current: mockNavigationContainer }); + + const span = getActiveSpan(); + expect(span).toBeDefined(); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Route', + }), + ); + }); + + test('afterAllSetup does not create span when navigation container is not registered', () => { + const instrumentation = reactNavigationIntegration(); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [instrumentation], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // No navigation container registered — no span should be created + expect(getActiveSpan()).toBeUndefined(); + }); + + test('subsequent navigations work after deferred initial span', async () => { + const instrumentation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + }); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [instrumentation], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + mockNavigation = createMockNavigationAndAttachTo(instrumentation); + + // Flush the initial span + jest.runOnlyPendingTimers(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + }), + ); + + // Navigate to a new screen + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'New Screen', + }), + ); + }); + + test('runApplication creates span after deferred initial state is handled', async () => { + const { mockedOnRunApplication } = mockAppRegistryIntegration(); + + const instrumentation = reactNavigationIntegration(); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [instrumentation], + enableAppStartTracking: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Register container after init + const mockNavigationContainer = new MockNavigationContainer(); + instrumentation.registerNavigationContainer({ current: mockNavigationContainer }); + + // Flush initial span + await jest.runOnlyPendingTimersAsync(); + + // Simulate app restart via runApplication + const runApplicationCallback = mockedOnRunApplication.mock.calls[0][0]; + runApplicationCallback(); + + const span = getActiveSpan(); + expect(span).toBeDefined(); + expect(spanToJSON(span!).op).toBe('navigation'); + }); + }); + describe('options', () => { test('waits until routeChangeTimeoutMs', () => { const instrumentation = reactNavigationIntegration({