@rejourneyco/react-native 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +54 -0
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +3 -0
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +0 -7
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +4 -1
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +3 -0
  9. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +26 -0
  10. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  11. package/ios/Engine/RejourneyImpl.swift +5 -0
  12. package/ios/Recording/RejourneyURLProtocol.swift +58 -10
  13. package/ios/Recording/ReplayOrchestrator.swift +3 -1
  14. package/ios/Recording/TelemetryPipeline.swift +28 -2
  15. package/ios/Recording/VisualCapture.swift +25 -21
  16. package/ios/Rejourney.h +4 -0
  17. package/ios/Rejourney.mm +3 -15
  18. package/ios/Utility/DataCompression.swift +2 -2
  19. package/lib/commonjs/expoRouterTracking.js +137 -0
  20. package/lib/commonjs/index.js +176 -19
  21. package/lib/commonjs/sdk/autoTracking.js +100 -89
  22. package/lib/module/expoRouterTracking.js +135 -0
  23. package/lib/module/index.js +175 -13
  24. package/lib/module/sdk/autoTracking.js +98 -89
  25. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  26. package/lib/typescript/index.d.ts +2 -2
  27. package/lib/typescript/sdk/autoTracking.d.ts +11 -0
  28. package/lib/typescript/types/index.d.ts +42 -3
  29. package/package.json +22 -2
  30. package/rejourney.podspec +11 -2
  31. package/src/expoRouterTracking.ts +167 -0
  32. package/src/index.ts +184 -16
  33. package/src/sdk/autoTracking.ts +110 -103
  34. package/src/types/index.ts +43 -3
@@ -160,7 +160,12 @@ const noopAutoTracking = {
160
160
  getSessionMetrics: () => ({}),
161
161
  resetMetrics: () => {},
162
162
  collectDeviceInfo: async () => ({}),
163
- ensurePersistentAnonymousId: async () => 'anonymous'
163
+ ensurePersistentAnonymousId: async () => 'anonymous',
164
+ useNavigationTracking: () => ({
165
+ ref: null,
166
+ onReady: () => {},
167
+ onStateChange: () => {}
168
+ })
164
169
  };
165
170
  function getAutoTracking() {
166
171
  if (_sdkDisabled) return noopAutoTracking;
@@ -183,6 +188,11 @@ let _appStateSubscription = null;
183
188
  let _authErrorSubscription = null;
184
189
  let _currentAppState = 'active'; // Default to active, will be updated on init
185
190
  let _userIdentity = null;
191
+ let _backgroundEntryTime = null; // Track when app went to background
192
+ let _storedMetadata = {}; // Accumulate metadata for session rollover
193
+
194
+ // Session timeout - must match native side (60 seconds)
195
+ const SESSION_TIMEOUT_MS = 60_000;
186
196
 
187
197
  // Scroll throttling - reduce native bridge calls from 60fps to at most 10/sec
188
198
  let _lastScrollTime = 0;
@@ -437,7 +447,7 @@ function safeNativeCallSync(methodName, fn, defaultValue) {
437
447
  /**
438
448
  * Main Rejourney API (Internal)
439
449
  */
440
- const Rejourney = {
450
+ export const Rejourney = {
441
451
  /**
442
452
  * SDK Version
443
453
  */
@@ -573,7 +583,8 @@ const Rejourney = {
573
583
  trackPromiseRejections: true,
574
584
  trackReactNativeErrors: true,
575
585
  trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
576
- collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false
586
+ collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false,
587
+ autoTrackExpoRouter: _storedConfig?.autoTrackExpoRouter !== false
577
588
  }, {
578
589
  // Rage tap callback - log as frustration event
579
590
  onRageTap: (count, x, y) => {
@@ -606,7 +617,7 @@ const Rejourney = {
606
617
  // RejourneyNetworkInterceptor on Android) are supplementary — they capture
607
618
  // native-originated HTTP calls that bypass JS fetch(), but cannot intercept
608
619
  // RN's own networking since it creates its NSURLSession/OkHttpClient at init time.
609
- const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', ...(_storedConfig?.networkIgnoreUrls || [])];
620
+ const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', '/api/ingest/segment/presign', '/api/ingest/segment/complete', ...(_storedConfig?.networkIgnoreUrls || [])];
610
621
  getNetworkInterceptor().initNetworkInterceptor(request => {
611
622
  getAutoTracking().trackAPIRequest(request.success || false, request.statusCode, request.duration || 0, request.responseBodySize || 0);
612
623
  Rejourney.logNetworkRequest(request);
@@ -695,17 +706,57 @@ const Rejourney = {
695
706
  }
696
707
  },
697
708
  /**
698
- * Tag the current screen
709
+ /**
710
+ * Set custom session metadata.
711
+ * Can be called with a single key-value pair or an object of properties.
712
+ * Useful for filtering sessions later (e.g., plan: 'premium', role: 'admin').
713
+ * Caps at 100 properties per session.
714
+ *
715
+ * @param keyOrProperties Property name string, or an object containing key-value pairs
716
+ * @param value Property value (if first argument is a string)
717
+ */
718
+ setMetadata(keyOrProperties, value) {
719
+ if (typeof keyOrProperties === 'string') {
720
+ const key = keyOrProperties;
721
+ if (!key) {
722
+ getLogger().warn('setMetadata requires a non-empty string key');
723
+ return;
724
+ }
725
+ if (value !== undefined && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
726
+ getLogger().warn('setMetadata value must be a string, number, or boolean when using a key string');
727
+ return;
728
+ }
729
+ this.logEvent('$user_property', {
730
+ key,
731
+ value
732
+ });
733
+ // Track for session rollover restoration
734
+ _storedMetadata[key] = value;
735
+ } else if (keyOrProperties && typeof keyOrProperties === 'object') {
736
+ const properties = keyOrProperties;
737
+ const validProps = {};
738
+ for (const [k, v] of Object.entries(properties)) {
739
+ if (typeof k === 'string' && k && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
740
+ validProps[k] = v;
741
+ }
742
+ }
743
+ if (Object.keys(validProps).length > 0) {
744
+ this.logEvent('$user_property', validProps);
745
+ // Track for session rollover restoration
746
+ Object.assign(_storedMetadata, validProps);
747
+ }
748
+ } else {
749
+ getLogger().warn('setMetadata requires a string key and value, or a properties object');
750
+ }
751
+ },
752
+ /**
753
+ * Track current screen (manual)
699
754
  *
700
755
  * @param screenName - Screen name
701
756
  * @param params - Optional screen parameters
702
757
  */
703
- tagScreen(screenName, _params) {
758
+ trackScreen(screenName, _params) {
704
759
  getAutoTracking().trackScreen(screenName);
705
- getAutoTracking().notifyStateChange();
706
- safeNativeCallSync('tagScreen', () => {
707
- getRejourneyNative().screenChanged(screenName).catch(() => {});
708
- }, undefined);
709
760
  },
710
761
  /**
711
762
  * Mark a view as sensitive (will be occluded in recordings)
@@ -1094,13 +1145,109 @@ const Rejourney = {
1094
1145
  safeNativeCallSync('unmaskView', () => {
1095
1146
  getRejourneyNative().unmaskViewByNativeID(nativeID).catch(() => {});
1096
1147
  }, undefined);
1148
+ },
1149
+ /**
1150
+ * Initialize Rejourney SDK
1151
+ */
1152
+ init(publicRouteKey, options) {
1153
+ initRejourney(publicRouteKey, options);
1154
+ },
1155
+ /**
1156
+ * Start recording
1157
+ */
1158
+ start() {
1159
+ startRejourney();
1160
+ },
1161
+ /**
1162
+ * Stop recording
1163
+ */
1164
+ stop() {
1165
+ stopRejourney();
1166
+ },
1167
+ /**
1168
+ * Hook for automatic React Navigation tracking.
1169
+ */
1170
+ useNavigationTracking() {
1171
+ return getAutoTracking().useNavigationTracking();
1097
1172
  }
1098
1173
  };
1099
1174
 
1175
+ /**
1176
+ * Reinitialize JS-side auto-tracking for a new session after background timeout.
1177
+ *
1178
+ * When the app was in background for >60s the native layer rolls over to a
1179
+ * fresh session automatically. The JS side must tear down stale tracking
1180
+ * state (metrics, console-log counter, screen history, error handlers) and
1181
+ * re-initialize so that trackScreen, logEvent, setMetadata, etc. work
1182
+ * correctly against the new native session.
1183
+ */
1184
+ function _reinitAutoTrackingForNewSession() {
1185
+ try {
1186
+ // 1. Tear down old session's auto-tracking state
1187
+ getAutoTracking().cleanupAutoTracking();
1188
+
1189
+ // 2. Re-initialize auto-tracking with the same config
1190
+ getAutoTracking().initAutoTracking({
1191
+ rageTapThreshold: _storedConfig?.rageTapThreshold ?? 3,
1192
+ rageTapTimeWindow: _storedConfig?.rageTapTimeWindow ?? 500,
1193
+ rageTapRadius: 50,
1194
+ trackJSErrors: true,
1195
+ trackPromiseRejections: true,
1196
+ trackReactNativeErrors: true,
1197
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
1198
+ collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false,
1199
+ autoTrackExpoRouter: _storedConfig?.autoTrackExpoRouter !== false
1200
+ }, {
1201
+ onRageTap: (count, x, y) => {
1202
+ Rejourney.logEvent('frustration', {
1203
+ frustrationKind: 'rage_tap',
1204
+ tapCount: count,
1205
+ x,
1206
+ y
1207
+ });
1208
+ getLogger().logFrustration(`Rage tap (${count} taps)`);
1209
+ },
1210
+ onError: error => {
1211
+ getLogger().logError(error.message);
1212
+ },
1213
+ onScreen: (_screenName, _previousScreen) => {}
1214
+ });
1215
+
1216
+ // 3. Re-collect device info for the new session
1217
+ if (_storedConfig?.collectDeviceInfo !== false) {
1218
+ getAutoTracking().collectDeviceInfo().then(deviceInfo => {
1219
+ Rejourney.logEvent('device_info', deviceInfo);
1220
+ }).catch(() => {});
1221
+ }
1222
+
1223
+ // 4. Re-send user identity to the new native session
1224
+ if (_userIdentity) {
1225
+ safeNativeCallSync('setUserIdentity', () => {
1226
+ getRejourneyNative().setUserIdentity(_userIdentity).catch(() => {});
1227
+ }, undefined);
1228
+ getLogger().debug(`Restored user identity '${_userIdentity}' to new session`);
1229
+ }
1230
+
1231
+ // 5. Re-send any stored metadata to the new native session
1232
+ if (Object.keys(_storedMetadata).length > 0) {
1233
+ for (const [key, value] of Object.entries(_storedMetadata)) {
1234
+ if (value !== undefined && value !== null) {
1235
+ Rejourney.setMetadata(key, value);
1236
+ }
1237
+ }
1238
+ getLogger().debug('Restored metadata to new session');
1239
+ }
1240
+ getLogger().logLifecycleEvent('JS auto-tracking reinitialized for new session');
1241
+ } catch (error) {
1242
+ getLogger().warn('Failed to reinitialize auto-tracking after session rollover:', error);
1243
+ }
1244
+ }
1245
+
1100
1246
  /**
1101
1247
  * Handle app state changes for automatic session management
1102
1248
  * - Pauses recording when app goes to background
1103
1249
  * - Resumes recording when app comes back to foreground
1250
+ * - Reinitializes JS-side auto-tracking when native rolls over to a new session
1104
1251
  * - Cleans up properly when app is terminated
1105
1252
  */
1106
1253
  function handleAppStateChange(nextAppState) {
@@ -1109,9 +1256,22 @@ function handleAppStateChange(nextAppState) {
1109
1256
  if (_currentAppState.match(/active/) && nextAppState === 'background') {
1110
1257
  // App going to background - native module handles this automatically
1111
1258
  getLogger().logLifecycleEvent('App moving to background');
1259
+ _backgroundEntryTime = Date.now();
1112
1260
  } else if (_currentAppState.match(/inactive|background/) && nextAppState === 'active') {
1113
1261
  // App coming back to foreground
1114
1262
  getLogger().logLifecycleEvent('App returning to foreground');
1263
+
1264
+ // Check if we exceeded the session timeout (60s).
1265
+ // Native side will have already ended the old session and started a new
1266
+ // one — we need to reset JS-side auto-tracking state to match.
1267
+ if (_backgroundEntryTime && _isRecording) {
1268
+ const backgroundDurationMs = Date.now() - _backgroundEntryTime;
1269
+ if (backgroundDurationMs > SESSION_TIMEOUT_MS) {
1270
+ getLogger().debug(`Session rollover: background ${Math.round(backgroundDurationMs / 1000)}s > ${SESSION_TIMEOUT_MS / 1000}s timeout`);
1271
+ _reinitAutoTrackingForNewSession();
1272
+ }
1273
+ }
1274
+ _backgroundEntryTime = null;
1115
1275
  }
1116
1276
  _currentAppState = nextAppState;
1117
1277
  } catch (error) {
@@ -1176,8 +1336,10 @@ function setupAuthErrorListener() {
1176
1336
  }
1177
1337
  });
1178
1338
  }
1179
- } catch (error) {
1180
- getLogger().debug('Auth error listener not available:', error);
1339
+ } catch {
1340
+ // Expected on some architectures where NativeEventEmitter isn't fully supported.
1341
+ // Auth errors are still handled synchronously via native callback — this listener
1342
+ // is purely supplementary. No need to log.
1181
1343
  }
1182
1344
  }
1183
1345
 
@@ -1314,7 +1476,7 @@ export function stopRejourney() {
1314
1476
  }
1315
1477
  export default Rejourney;
1316
1478
  export * from './types';
1317
- export { trackTap, trackScroll, trackGesture, trackInput, trackScreen, captureError, getSessionMetrics } from './sdk/autoTracking';
1479
+ export { trackTap, trackScroll, trackGesture, trackInput, captureError, getSessionMetrics } from './sdk/autoTracking';
1318
1480
  export { trackNavigationState, useNavigationTracking } from './sdk/autoTracking';
1319
1481
  export { LogLevel } from './sdk/utils';
1320
1482
 
@@ -119,6 +119,7 @@ export function initAutoTracking(trackingConfig, callbacks = {}) {
119
119
  trackReactNativeErrors: true,
120
120
  trackConsoleLogs: true,
121
121
  collectDeviceInfo: true,
122
+ autoTrackExpoRouter: true,
122
123
  maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
123
124
  ...trackingConfig
124
125
  };
@@ -568,10 +569,26 @@ function restoreConsoleHandlers() {
568
569
  // Note: console.error is restored in restoreErrorHandlers via originalConsoleError
569
570
  }
570
571
  let navigationPollingInterval = null;
572
+ /** Interval ID from optional expo-router entry; cleared in cleanupNavigationTracking */
573
+ let expoRouterPollingIntervalId = null;
571
574
  let lastDetectedScreen = '';
572
575
  let navigationSetupDone = false;
573
- let navigationPollingErrors = 0;
574
- const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
576
+
577
+ /**
578
+ * Register the polling interval from the optional expo-router entry so we can clear it on cleanup.
579
+ * Used by src/expoRouterTracking.ts (only loaded when app imports '@rejourneyco/react-native/expo-router').
580
+ */
581
+ export function setExpoRouterPollingInterval(id) {
582
+ expoRouterPollingIntervalId = id;
583
+ }
584
+
585
+ /**
586
+ * Check if Expo Router auto-tracking is enabled in the current configuration.
587
+ * Used by src/expoRouterTracking.ts.
588
+ */
589
+ export function isExpoRouterTrackingEnabled() {
590
+ return config.autoTrackExpoRouter !== false;
591
+ }
575
592
 
576
593
  /**
577
594
  * Track a navigation state change from React Navigation.
@@ -668,91 +685,94 @@ export function useNavigationTracking() {
668
685
  }
669
686
 
670
687
  /**
671
- * Setup automatic Expo Router tracking
672
- *
673
- * For Expo apps using expo-router - works automatically.
674
- * For bare React Native apps - use trackNavigationState instead.
688
+ * Setup automatic navigation tracking.
689
+ *
690
+ * Expo Router: not set up here to avoid pulling expo-router into the main bundle
691
+ * (Metro resolves require() at build time, which causes "Requiring unknown module"
692
+ * in apps that use Expo + react-navigation without expo-router). If you use
693
+ * expo-router, add: import '@rejourneyco/react-native/expo-router';
694
+ *
695
+ * For React Navigation (non–expo-router), use trackNavigationState() on your
696
+ * NavigationContainer's onStateChange.
675
697
  */
676
698
  function setupNavigationTracking() {
677
699
  if (navigationSetupDone) return;
678
700
  navigationSetupDone = true;
679
- if (__DEV__) {
680
- logger.debug('Setting up navigation tracking...');
701
+
702
+ // Auto-detect expo-router and set up screen tracking if available.
703
+ // This is safe: if expo-router isn't installed, the require fails silently.
704
+ // We defer slightly so the router has time to initialize after JS bundle load.
705
+ if (config.autoTrackExpoRouter !== false) {
706
+ tryAutoSetupExpoRouter();
681
707
  }
682
- let attempts = 0;
683
- const maxAttempts = 5;
684
- const trySetup = () => {
685
- attempts++;
686
- if (__DEV__) {
687
- logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
688
- }
689
- const success = trySetupExpoRouter();
690
- if (success) {
691
- if (__DEV__) {
692
- logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
693
- }
694
- } else if (attempts < maxAttempts) {
695
- const delay = 200 * attempts;
696
- if (__DEV__) {
697
- logger.debug('Expo Router not ready, retrying in', delay, 'ms');
708
+ }
709
+
710
+ /**
711
+ * Attempt to auto-detect and set up expo-router screen tracking.
712
+ * Uses a retry mechanism because the router may not be ready immediately
713
+ * after JS bundle load.
714
+ */
715
+ function tryAutoSetupExpoRouter(attempt = 0, maxAttempts = 5) {
716
+ const delay = 200 * (attempt + 1); // 200, 400, 600, 800, 1000ms
717
+
718
+ setTimeout(() => {
719
+ try {
720
+ // Dynamic require wrapped in a variable to prevent Metro from statically resolving it
721
+ const EXPO_ROUTER = 'expo-router';
722
+ const expoRouter = require(EXPO_ROUTER);
723
+ if (!expoRouter?.router) {
724
+ // expo-router exists but router not ready yet — retry
725
+ if (attempt < maxAttempts - 1) {
726
+ tryAutoSetupExpoRouter(attempt + 1, maxAttempts);
727
+ }
728
+ return;
698
729
  }
699
- setTimeout(trySetup, delay);
700
- } else {
701
- if (__DEV__) {
702
- logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
703
- logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
730
+
731
+ // Router is ready — set up the polling-based screen tracker
732
+ setupExpoRouterPolling(expoRouter.router);
733
+ } catch {
734
+ // expo-router not installed — this is fine, just means the app
735
+ // uses bare React Navigation or no navigation at all.
736
+ if (__DEV__ && attempt === 0) {
737
+ logger.debug('Expo Router not detected, skipping auto screen tracking. Use trackNavigationState() for React Navigation.');
704
738
  }
705
739
  }
706
- };
707
- setTimeout(trySetup, 200);
740
+ }, delay);
708
741
  }
709
742
 
710
743
  /**
711
- * Set up Expo Router auto-tracking by polling the internal router store
712
- *
713
- * Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
714
- */
715
- function trySetupExpoRouter() {
744
+ * Poll expo-router state for screen changes.
745
+ * Inlined from expoRouterTracking.ts so no separate import is needed.
746
+ */
747
+ function setupExpoRouterPolling(router) {
748
+ // Guard against double-setup (core auto-detection + legacy expoRouterTracking.ts import)
749
+ if (expoRouterPollingIntervalId != null) return;
750
+ const MAX_POLLING_ERRORS = 10;
751
+ let pollingErrors = 0;
716
752
  try {
717
- const expoRouter = require('expo-router');
718
- const router = expoRouter.router;
719
- if (!router) {
720
- if (__DEV__) {
721
- logger.debug('Expo Router: router object not found');
722
- }
723
- return false;
724
- }
725
- if (__DEV__) {
726
- logger.debug('Expo Router: Setting up navigation tracking');
727
- }
728
753
  const {
729
754
  normalizeScreenName,
730
755
  getScreenNameFromPath
731
756
  } = require('./navigation');
732
- navigationPollingInterval = setInterval(() => {
757
+ const intervalId = setInterval(() => {
733
758
  try {
734
759
  let state = null;
735
- let stateSource = '';
736
760
  if (typeof router.getState === 'function') {
737
761
  state = router.getState();
738
- stateSource = 'router.getState()';
739
762
  } else if (router.rootState) {
740
763
  state = router.rootState;
741
- stateSource = 'router.rootState';
742
764
  }
743
765
  if (!state) {
744
766
  try {
745
- const storeModule = require('expo-router/build/global-state/router-store');
767
+ const STORE_PATH = 'expo-router/build/global-state/router-store';
768
+ const storeModule = require(STORE_PATH);
746
769
  if (storeModule?.store) {
747
770
  state = storeModule.store.state;
748
- if (state) stateSource = 'store.state';
749
771
  if (!state && storeModule.store.navigationRef?.current) {
750
772
  state = storeModule.store.navigationRef.current.getRootState?.();
751
- if (state) stateSource = 'navigationRef.getRootState()';
752
773
  }
753
774
  if (!state) {
754
775
  state = storeModule.store.rootState || storeModule.store.initialState;
755
- if (state) stateSource = 'store.rootState/initialState';
756
776
  }
757
777
  }
758
778
  } catch {
@@ -761,67 +781,53 @@ function trySetupExpoRouter() {
761
781
  }
762
782
  if (!state) {
763
783
  try {
764
- const imperative = require('expo-router/build/imperative-api');
784
+ const IMPERATIVE_PATH = 'expo-router/build/imperative-api';
785
+ const imperative = require(IMPERATIVE_PATH);
765
786
  if (imperative?.router) {
766
787
  state = imperative.router.getState?.();
767
- if (state) stateSource = 'imperative-api';
768
788
  }
769
789
  } catch {
770
790
  // Ignore
771
791
  }
772
792
  }
773
793
  if (state) {
774
- navigationPollingErrors = 0;
775
- navigationPollingErrors = 0;
794
+ pollingErrors = 0;
776
795
  const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
777
796
  if (screenName && screenName !== lastDetectedScreen) {
778
- if (__DEV__) {
779
- logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
780
- }
781
797
  lastDetectedScreen = screenName;
782
798
  trackScreen(screenName);
783
799
  }
784
800
  } else {
785
- navigationPollingErrors++;
786
- if (__DEV__ && navigationPollingErrors === 1) {
787
- logger.debug('Expo Router: Could not get navigation state');
788
- }
789
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
790
- cleanupNavigationTracking();
801
+ pollingErrors++;
802
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
803
+ clearInterval(intervalId);
804
+ expoRouterPollingIntervalId = null;
791
805
  }
792
806
  }
793
- } catch (e) {
794
- navigationPollingErrors++;
795
- if (__DEV__ && navigationPollingErrors === 1) {
796
- logger.debug('Expo Router polling error:', e);
797
- }
798
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
799
- cleanupNavigationTracking();
807
+ } catch {
808
+ pollingErrors++;
809
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
810
+ clearInterval(intervalId);
811
+ expoRouterPollingIntervalId = null;
800
812
  }
801
813
  }
802
814
  }, 500);
803
- return true;
804
- } catch (e) {
805
- if (__DEV__) {
806
- logger.debug('Expo Router not available:', e);
807
- }
808
- return false;
815
+ expoRouterPollingIntervalId = intervalId;
816
+ } catch {
817
+ // navigation module not available — ignore
809
818
  }
810
819
  }
811
820
 
812
821
  /**
813
- * Extract screen name from Expo Router navigation state
814
- *
815
- * Handles complex nested structures like Drawer → Tabs → Stack
816
- * by recursively accumulating segments from each navigation level.
822
+ * Extract the active screen name from expo-router navigation state.
817
823
  */
818
- function extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName, accumulatedSegments = []) {
824
+ function extractScreenNameFromRouterState(state, getScreenNameFromPathFn, normalizeScreenNameFn, accumulatedSegments = []) {
819
825
  if (!state?.routes) return null;
820
826
  const route = state.routes[state.index ?? state.routes.length - 1];
821
827
  if (!route) return null;
822
828
  const newSegments = [...accumulatedSegments, route.name];
823
829
  if (route.state) {
824
- return extractScreenNameFromRouterState(route.state, getScreenNameFromPath, normalizeScreenName, newSegments);
830
+ return extractScreenNameFromRouterState(route.state, getScreenNameFromPathFn, normalizeScreenNameFn, newSegments);
825
831
  }
826
832
  const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
827
833
  if (cleanSegments.length === 0) {
@@ -834,7 +840,7 @@ function extractScreenNameFromRouterState(state, getScreenNameFromPath, normaliz
834
840
  }
835
841
  }
836
842
  const pathname = '/' + cleanSegments.join('/');
837
- return getScreenNameFromPath(pathname, newSegments);
843
+ return getScreenNameFromPathFn(pathname, newSegments);
838
844
  }
839
845
 
840
846
  /**
@@ -845,9 +851,12 @@ function cleanupNavigationTracking() {
845
851
  clearInterval(navigationPollingInterval);
846
852
  navigationPollingInterval = null;
847
853
  }
854
+ if (expoRouterPollingIntervalId != null) {
855
+ clearInterval(expoRouterPollingIntervalId);
856
+ expoRouterPollingIntervalId = null;
857
+ }
848
858
  navigationSetupDone = false;
849
859
  lastDetectedScreen = '';
850
- navigationPollingErrors = 0;
851
860
  }
852
861
 
853
862
  /**
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Optional Expo Router integration for @rejourneyco/react-native
3
+ *
4
+ * This file is only loaded when you import '@rejourneyco/react-native/expo-router'.
5
+ * It contains require('expo-router') and related subpaths. Metro bundles require()
6
+ * at build time, so keeping this in a separate entry ensures apps that use
7
+ * Expo with react-navigation (without expo-router) never pull in expo-router
8
+ * and avoid "Requiring unknown module" crashes.
9
+ *
10
+ * If you use expo-router, add this once (e.g. in your root _layout.tsx):
11
+ * import '@rejourneyco/react-native/expo-router';
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=expoRouterTracking.d.ts.map
@@ -26,7 +26,7 @@ import type { RejourneyConfig, RejourneyAPI } from './types';
26
26
  /**
27
27
  * Main Rejourney API (Internal)
28
28
  */
29
- declare const Rejourney: RejourneyAPI;
29
+ export declare const Rejourney: RejourneyAPI;
30
30
  /**
31
31
  * Initialize Rejourney SDK - STEP 1 of 3
32
32
  *
@@ -81,7 +81,7 @@ export declare function startRejourney(): void;
81
81
  export declare function stopRejourney(): void;
82
82
  export default Rejourney;
83
83
  export * from './types';
84
- export { trackTap, trackScroll, trackGesture, trackInput, trackScreen, captureError, getSessionMetrics, } from './sdk/autoTracking';
84
+ export { trackTap, trackScroll, trackGesture, trackInput, captureError, getSessionMetrics, } from './sdk/autoTracking';
85
85
  export { trackNavigationState, useNavigationTracking } from './sdk/autoTracking';
86
86
  export { LogLevel } from './sdk/utils';
87
87
  /**
@@ -68,6 +68,7 @@ export interface AutoTrackingConfig {
68
68
  collectDeviceInfo?: boolean;
69
69
  maxSessionDurationMs?: number;
70
70
  detectDeadTaps?: boolean;
71
+ autoTrackExpoRouter?: boolean;
71
72
  }
72
73
  /**
73
74
  * Mark a tap as handled.
@@ -101,6 +102,16 @@ export declare function notifyStateChange(): void;
101
102
  * Manually track an error (for API errors, etc.)
102
103
  */
103
104
  export declare function captureError(message: string, stack?: string, name?: string): void;
105
+ /**
106
+ * Register the polling interval from the optional expo-router entry so we can clear it on cleanup.
107
+ * Used by src/expoRouterTracking.ts (only loaded when app imports '@rejourneyco/react-native/expo-router').
108
+ */
109
+ export declare function setExpoRouterPollingInterval(id: ReturnType<typeof setInterval> | null): void;
110
+ /**
111
+ * Check if Expo Router auto-tracking is enabled in the current configuration.
112
+ * Used by src/expoRouterTracking.ts.
113
+ */
114
+ export declare function isExpoRouterTrackingEnabled(): boolean;
104
115
  /**
105
116
  * Track a navigation state change from React Navigation.
106
117
  *