@rejourneyco/react-native 1.0.8 → 1.0.10

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 (52) 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 +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. package/src/types/index.ts +58 -6
@@ -13,11 +13,13 @@ exports.getRemainingSessionDurationMs = getRemainingSessionDurationMs;
13
13
  exports.getSessionMetrics = getSessionMetrics;
14
14
  exports.hasExceededMaxSessionDuration = hasExceededMaxSessionDuration;
15
15
  exports.initAutoTracking = initAutoTracking;
16
+ exports.isExpoRouterTrackingEnabled = isExpoRouterTrackingEnabled;
16
17
  exports.loadAnonymousId = loadAnonymousId;
17
18
  exports.markTapHandled = markTapHandled;
18
19
  exports.notifyStateChange = notifyStateChange;
19
20
  exports.resetMetrics = resetMetrics;
20
21
  exports.setAnonymousId = setAnonymousId;
22
+ exports.setExpoRouterPollingInterval = setExpoRouterPollingInterval;
21
23
  exports.setMaxSessionDurationMinutes = setMaxSessionDurationMinutes;
22
24
  exports.trackAPIRequest = trackAPIRequest;
23
25
  exports.trackGesture = trackGesture;
@@ -130,6 +132,7 @@ let originalOnError = null;
130
132
  let originalOnUnhandledRejection = null;
131
133
  let originalConsoleError = null;
132
134
  let _promiseRejectionTrackingDisable = null;
135
+ const FATAL_ERROR_FLUSH_DELAY_MS = 1200;
133
136
 
134
137
  /**
135
138
  * Initialize auto tracking features
@@ -144,7 +147,9 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
144
147
  trackJSErrors: true,
145
148
  trackPromiseRejections: true,
146
149
  trackReactNativeErrors: true,
150
+ trackConsoleLogs: true,
147
151
  collectDeviceInfo: true,
152
+ autoTrackExpoRouter: true,
148
153
  maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
149
154
  ...trackingConfig
150
155
  };
@@ -154,6 +159,9 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
154
159
  onErrorCaptured = callbacks.onError || null;
155
160
  onScreenChange = callbacks.onScreen || null;
156
161
  setupErrorTracking();
162
+ if (config.trackConsoleLogs) {
163
+ setupConsoleTracking();
164
+ }
157
165
  setupNavigationTracking();
158
166
  loadAnonymousId().then(id => {
159
167
  anonymousId = id;
@@ -167,11 +175,13 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
167
175
  function cleanupAutoTracking() {
168
176
  if (!isInitialized) return;
169
177
  restoreErrorHandlers();
178
+ restoreConsoleHandlers();
170
179
  cleanupNavigationTracking();
171
180
 
172
181
  // Reset state
173
182
  tapHead = 0;
174
183
  tapCount = 0;
184
+ consoleLogCount = 0;
175
185
  metrics = createEmptyMetrics();
176
186
  screensVisited = [];
177
187
  currentScreen = '';
@@ -285,7 +295,7 @@ function setupErrorTracking() {
285
295
  /**
286
296
  * Setup React Native ErrorUtils handler
287
297
  *
288
- * CRITICAL FIX: For fatal errors, we delay calling the original handler by 500ms
298
+ * CRITICAL FIX: For fatal errors, we delay calling the original handler briefly
289
299
  * to give the React Native bridge time to flush the logEvent('error') call to the
290
300
  * native TelemetryPipeline. Without this delay, the error event is queued on the
291
301
  * JS→native bridge but the app crashes (via originalErrorHandler) before the bridge
@@ -309,10 +319,10 @@ function setupReactNativeErrorHandler() {
309
319
  if (isFatal) {
310
320
  // For fatal errors, delay the original handler so the native bridge
311
321
  // has time to deliver the error event to TelemetryPipeline before
312
- // the app terminates. 500ms is enough for the bridge to flush.
322
+ // the app terminates.
313
323
  setTimeout(() => {
314
324
  originalErrorHandler(error, isFatal);
315
- }, 500);
325
+ }, FATAL_ERROR_FLUSH_DELAY_MS);
316
326
  } else {
317
327
  originalErrorHandler(error, isFatal);
318
328
  }
@@ -367,7 +377,6 @@ function setupPromiseRejectionHandler() {
367
377
 
368
378
  // Strategy 1: RN-specific promise rejection tracking polyfill
369
379
  try {
370
- // eslint-disable-next-line @typescript-eslint/no-var-requires
371
380
  const tracking = require('promise/setimmediate/rejection-tracking');
372
381
  if (tracking && typeof tracking.enable === 'function') {
373
382
  tracking.enable({
@@ -476,8 +485,27 @@ function restoreErrorHandlers() {
476
485
  function trackError(error) {
477
486
  metrics.errorCount++;
478
487
  metrics.totalEvents++;
488
+ forwardErrorToNative(error);
479
489
  if (onErrorCaptured) {
480
- onErrorCaptured(error);
490
+ try {
491
+ onErrorCaptured(error);
492
+ } catch {
493
+ // Ignore callback exceptions so SDK error forwarding keeps working.
494
+ }
495
+ }
496
+ }
497
+ function forwardErrorToNative(error) {
498
+ try {
499
+ const nativeModule = getRejourneyNativeModule();
500
+ if (!nativeModule || typeof nativeModule.logEvent !== 'function') return;
501
+ nativeModule.logEvent('error', {
502
+ message: error.message,
503
+ stack: error.stack,
504
+ name: error.name || 'Error',
505
+ timestamp: error.timestamp
506
+ }).catch(() => {});
507
+ } catch {
508
+ // Ignore native forwarding failures; SDK should never crash app code.
481
509
  }
482
510
  }
483
511
 
@@ -493,11 +521,104 @@ function captureError(message, stack, name) {
493
521
  name: name || 'Error'
494
522
  });
495
523
  }
524
+ let originalConsoleLog = null;
525
+ let originalConsoleInfo = null;
526
+ let originalConsoleWarn = null;
527
+
528
+ // Cap console logs to prevent flooding the event pipeline
529
+ const MAX_CONSOLE_LOGS_PER_SESSION = 1000;
530
+ let consoleLogCount = 0;
531
+
532
+ /**
533
+ * Setup console tracking to capture log statements
534
+ */
535
+ function setupConsoleTracking() {
536
+ if (typeof console === 'undefined') return;
537
+ if (!originalConsoleLog) originalConsoleLog = console.log;
538
+ if (!originalConsoleInfo) originalConsoleInfo = console.info;
539
+ if (!originalConsoleWarn) originalConsoleWarn = console.warn;
540
+ const createConsoleInterceptor = (level, originalFn) => {
541
+ return (...args) => {
542
+ try {
543
+ const message = args.map(arg => {
544
+ if (typeof arg === 'string') return arg;
545
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}${arg.stack ? `\n...` : ''}`;
546
+ try {
547
+ return JSON.stringify(arg);
548
+ } catch {
549
+ return String(arg);
550
+ }
551
+ }).join(' ');
552
+
553
+ // Enforce per-session cap and skip React Native unhandled-rejection noise.
554
+ if (consoleLogCount < MAX_CONSOLE_LOGS_PER_SESSION && !message.includes('Possible Unhandled Promise Rejection')) {
555
+ consoleLogCount++;
556
+ const nativeModule = getRejourneyNativeModule();
557
+ if (nativeModule) {
558
+ const logEvent = {
559
+ type: 'log',
560
+ timestamp: Date.now(),
561
+ level,
562
+ message: message.length > 2000 ? message.substring(0, 2000) + '...' : message
563
+ };
564
+ nativeModule.logEvent('log', logEvent).catch(() => {});
565
+ }
566
+ }
567
+ } catch {
568
+ // Ignore any errors during interception
569
+ }
570
+ if (originalFn) {
571
+ originalFn.apply(console, args);
572
+ }
573
+ };
574
+ };
575
+ console.log = createConsoleInterceptor('log', originalConsoleLog);
576
+ console.info = createConsoleInterceptor('info', originalConsoleInfo);
577
+ console.warn = createConsoleInterceptor('warn', originalConsoleWarn);
578
+ const currentConsoleError = console.error;
579
+ if (!originalConsoleError) originalConsoleError = currentConsoleError;
580
+ console.error = createConsoleInterceptor('error', currentConsoleError);
581
+ }
582
+
583
+ /**
584
+ * Restore console standard functions
585
+ */
586
+ function restoreConsoleHandlers() {
587
+ if (originalConsoleLog) {
588
+ console.log = originalConsoleLog;
589
+ originalConsoleLog = null;
590
+ }
591
+ if (originalConsoleInfo) {
592
+ console.info = originalConsoleInfo;
593
+ originalConsoleInfo = null;
594
+ }
595
+ if (originalConsoleWarn) {
596
+ console.warn = originalConsoleWarn;
597
+ originalConsoleWarn = null;
598
+ }
599
+ // Note: console.error is restored in restoreErrorHandlers via originalConsoleError
600
+ }
496
601
  let navigationPollingInterval = null;
602
+ /** Interval ID from optional expo-router entry; cleared in cleanupNavigationTracking */
603
+ let expoRouterPollingIntervalId = null;
497
604
  let lastDetectedScreen = '';
498
605
  let navigationSetupDone = false;
499
- let navigationPollingErrors = 0;
500
- const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
606
+
607
+ /**
608
+ * Register the polling interval from the optional expo-router entry so we can clear it on cleanup.
609
+ * Used by src/expoRouterTracking.ts (only loaded when app imports '@rejourneyco/react-native/expo-router').
610
+ */
611
+ function setExpoRouterPollingInterval(id) {
612
+ expoRouterPollingIntervalId = id;
613
+ }
614
+
615
+ /**
616
+ * Check if Expo Router auto-tracking is enabled in the current configuration.
617
+ * Used by src/expoRouterTracking.ts.
618
+ */
619
+ function isExpoRouterTrackingEnabled() {
620
+ return config.autoTrackExpoRouter !== false;
621
+ }
501
622
 
502
623
  /**
503
624
  * Track a navigation state change from React Navigation.
@@ -594,91 +715,94 @@ function useNavigationTracking() {
594
715
  }
595
716
 
596
717
  /**
597
- * Setup automatic Expo Router tracking
598
- *
599
- * For Expo apps using expo-router - works automatically.
600
- * For bare React Native apps - use trackNavigationState instead.
718
+ * Setup automatic navigation tracking.
719
+ *
720
+ * Expo Router: not set up here to avoid pulling expo-router into the main bundle
721
+ * (Metro resolves require() at build time, which causes "Requiring unknown module"
722
+ * in apps that use Expo + react-navigation without expo-router). If you use
723
+ * expo-router, add: import '@rejourneyco/react-native/expo-router';
724
+ *
725
+ * For React Navigation (non–expo-router), use trackNavigationState() on your
726
+ * NavigationContainer's onStateChange.
601
727
  */
602
728
  function setupNavigationTracking() {
603
729
  if (navigationSetupDone) return;
604
730
  navigationSetupDone = true;
605
- if (__DEV__) {
606
- _utils.logger.debug('Setting up navigation tracking...');
731
+
732
+ // Auto-detect expo-router and set up screen tracking if available.
733
+ // This is safe: if expo-router isn't installed, the require fails silently.
734
+ // We defer slightly so the router has time to initialize after JS bundle load.
735
+ if (config.autoTrackExpoRouter !== false) {
736
+ tryAutoSetupExpoRouter();
607
737
  }
608
- let attempts = 0;
609
- const maxAttempts = 5;
610
- const trySetup = () => {
611
- attempts++;
612
- if (__DEV__) {
613
- _utils.logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
614
- }
615
- const success = trySetupExpoRouter();
616
- if (success) {
617
- if (__DEV__) {
618
- _utils.logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
619
- }
620
- } else if (attempts < maxAttempts) {
621
- const delay = 200 * attempts;
622
- if (__DEV__) {
623
- _utils.logger.debug('Expo Router not ready, retrying in', delay, 'ms');
738
+ }
739
+
740
+ /**
741
+ * Attempt to auto-detect and set up expo-router screen tracking.
742
+ * Uses a retry mechanism because the router may not be ready immediately
743
+ * after JS bundle load.
744
+ */
745
+ function tryAutoSetupExpoRouter(attempt = 0, maxAttempts = 5) {
746
+ const delay = 200 * (attempt + 1); // 200, 400, 600, 800, 1000ms
747
+
748
+ setTimeout(() => {
749
+ try {
750
+ // Dynamic require wrapped in a variable to prevent Metro from statically resolving it
751
+ const EXPO_ROUTER = 'expo-router';
752
+ const expoRouter = require(EXPO_ROUTER);
753
+ if (!expoRouter?.router) {
754
+ // expo-router exists but router not ready yet — retry
755
+ if (attempt < maxAttempts - 1) {
756
+ tryAutoSetupExpoRouter(attempt + 1, maxAttempts);
757
+ }
758
+ return;
624
759
  }
625
- setTimeout(trySetup, delay);
626
- } else {
627
- if (__DEV__) {
628
- _utils.logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
629
- _utils.logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
760
+
761
+ // Router is ready — set up the polling-based screen tracker
762
+ setupExpoRouterPolling(expoRouter.router);
763
+ } catch {
764
+ // expo-router not installed — this is fine, just means the app
765
+ // uses bare React Navigation or no navigation at all.
766
+ if (__DEV__ && attempt === 0) {
767
+ _utils.logger.debug('Expo Router not detected, skipping auto screen tracking. Use trackNavigationState() for React Navigation.');
630
768
  }
631
769
  }
632
- };
633
- setTimeout(trySetup, 200);
770
+ }, delay);
634
771
  }
635
772
 
636
773
  /**
637
- * Set up Expo Router auto-tracking by polling the internal router store
638
- *
639
- * Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
640
- */
641
- function trySetupExpoRouter() {
774
+ * Poll expo-router state for screen changes.
775
+ * Inlined from expoRouterTracking.ts so no separate import is needed.
776
+ */
777
+ function setupExpoRouterPolling(router) {
778
+ // Guard against double-setup (core auto-detection + legacy expoRouterTracking.ts import)
779
+ if (expoRouterPollingIntervalId != null) return;
780
+ const MAX_POLLING_ERRORS = 10;
781
+ let pollingErrors = 0;
642
782
  try {
643
- const expoRouter = require('expo-router');
644
- const router = expoRouter.router;
645
- if (!router) {
646
- if (__DEV__) {
647
- _utils.logger.debug('Expo Router: router object not found');
648
- }
649
- return false;
650
- }
651
- if (__DEV__) {
652
- _utils.logger.debug('Expo Router: Setting up navigation tracking');
653
- }
654
783
  const {
655
784
  normalizeScreenName,
656
785
  getScreenNameFromPath
657
786
  } = require('./navigation');
658
- navigationPollingInterval = setInterval(() => {
787
+ const intervalId = setInterval(() => {
659
788
  try {
660
789
  let state = null;
661
- let stateSource = '';
662
790
  if (typeof router.getState === 'function') {
663
791
  state = router.getState();
664
- stateSource = 'router.getState()';
665
792
  } else if (router.rootState) {
666
793
  state = router.rootState;
667
- stateSource = 'router.rootState';
668
794
  }
669
795
  if (!state) {
670
796
  try {
671
- const storeModule = require('expo-router/build/global-state/router-store');
797
+ const STORE_PATH = 'expo-router/build/global-state/router-store';
798
+ const storeModule = require(STORE_PATH);
672
799
  if (storeModule?.store) {
673
800
  state = storeModule.store.state;
674
- if (state) stateSource = 'store.state';
675
801
  if (!state && storeModule.store.navigationRef?.current) {
676
802
  state = storeModule.store.navigationRef.current.getRootState?.();
677
- if (state) stateSource = 'navigationRef.getRootState()';
678
803
  }
679
804
  if (!state) {
680
805
  state = storeModule.store.rootState || storeModule.store.initialState;
681
- if (state) stateSource = 'store.rootState/initialState';
682
806
  }
683
807
  }
684
808
  } catch {
@@ -687,67 +811,53 @@ function trySetupExpoRouter() {
687
811
  }
688
812
  if (!state) {
689
813
  try {
690
- const imperative = require('expo-router/build/imperative-api');
814
+ const IMPERATIVE_PATH = 'expo-router/build/imperative-api';
815
+ const imperative = require(IMPERATIVE_PATH);
691
816
  if (imperative?.router) {
692
817
  state = imperative.router.getState?.();
693
- if (state) stateSource = 'imperative-api';
694
818
  }
695
819
  } catch {
696
820
  // Ignore
697
821
  }
698
822
  }
699
823
  if (state) {
700
- navigationPollingErrors = 0;
701
- navigationPollingErrors = 0;
824
+ pollingErrors = 0;
702
825
  const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
703
826
  if (screenName && screenName !== lastDetectedScreen) {
704
- if (__DEV__) {
705
- _utils.logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
706
- }
707
827
  lastDetectedScreen = screenName;
708
828
  trackScreen(screenName);
709
829
  }
710
830
  } else {
711
- navigationPollingErrors++;
712
- if (__DEV__ && navigationPollingErrors === 1) {
713
- _utils.logger.debug('Expo Router: Could not get navigation state');
714
- }
715
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
716
- cleanupNavigationTracking();
831
+ pollingErrors++;
832
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
833
+ clearInterval(intervalId);
834
+ expoRouterPollingIntervalId = null;
717
835
  }
718
836
  }
719
- } catch (e) {
720
- navigationPollingErrors++;
721
- if (__DEV__ && navigationPollingErrors === 1) {
722
- _utils.logger.debug('Expo Router polling error:', e);
723
- }
724
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
725
- cleanupNavigationTracking();
837
+ } catch {
838
+ pollingErrors++;
839
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
840
+ clearInterval(intervalId);
841
+ expoRouterPollingIntervalId = null;
726
842
  }
727
843
  }
728
844
  }, 500);
729
- return true;
730
- } catch (e) {
731
- if (__DEV__) {
732
- _utils.logger.debug('Expo Router not available:', e);
733
- }
734
- return false;
845
+ expoRouterPollingIntervalId = intervalId;
846
+ } catch {
847
+ // navigation module not available — ignore
735
848
  }
736
849
  }
737
850
 
738
851
  /**
739
- * Extract screen name from Expo Router navigation state
740
- *
741
- * Handles complex nested structures like Drawer → Tabs → Stack
742
- * by recursively accumulating segments from each navigation level.
852
+ * Extract the active screen name from expo-router navigation state.
743
853
  */
744
- function extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName, accumulatedSegments = []) {
854
+ function extractScreenNameFromRouterState(state, getScreenNameFromPathFn, normalizeScreenNameFn, accumulatedSegments = []) {
745
855
  if (!state?.routes) return null;
746
856
  const route = state.routes[state.index ?? state.routes.length - 1];
747
857
  if (!route) return null;
748
858
  const newSegments = [...accumulatedSegments, route.name];
749
859
  if (route.state) {
750
- return extractScreenNameFromRouterState(route.state, getScreenNameFromPath, normalizeScreenName, newSegments);
860
+ return extractScreenNameFromRouterState(route.state, getScreenNameFromPathFn, normalizeScreenNameFn, newSegments);
751
861
  }
752
862
  const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
753
863
  if (cleanSegments.length === 0) {
@@ -760,7 +870,7 @@ function extractScreenNameFromRouterState(state, getScreenNameFromPath, normaliz
760
870
  }
761
871
  }
762
872
  const pathname = '/' + cleanSegments.join('/');
763
- return getScreenNameFromPath(pathname, newSegments);
873
+ return getScreenNameFromPathFn(pathname, newSegments);
764
874
  }
765
875
 
766
876
  /**
@@ -771,9 +881,12 @@ function cleanupNavigationTracking() {
771
881
  clearInterval(navigationPollingInterval);
772
882
  navigationPollingInterval = null;
773
883
  }
884
+ if (expoRouterPollingIntervalId != null) {
885
+ clearInterval(expoRouterPollingIntervalId);
886
+ expoRouterPollingIntervalId = null;
887
+ }
774
888
  navigationSetupDone = false;
775
889
  lastDetectedScreen = '';
776
- navigationPollingErrors = 0;
777
890
  }
778
891
 
779
892
  /**
@@ -1016,7 +1129,26 @@ async function collectDeviceInfo() {
1016
1129
  function generateAnonymousId() {
1017
1130
  const timestamp = Date.now().toString(36);
1018
1131
  const random = Math.random().toString(36).substring(2, 15);
1019
- return `anon_${timestamp}_${random}`;
1132
+ const id = `anon_${timestamp}_${random}`;
1133
+ // Persist so the same ID survives app restarts
1134
+ _persistAnonymousId(id);
1135
+ return id;
1136
+ }
1137
+
1138
+ /**
1139
+ * Best-effort async persist of anonymous ID to native storage
1140
+ */
1141
+ function _persistAnonymousId(id) {
1142
+ const nativeModule = getRejourneyNativeModule();
1143
+ if (!nativeModule?.setAnonymousId) return;
1144
+ try {
1145
+ const result = nativeModule.setAnonymousId(id);
1146
+ if (result && typeof result.catch === 'function') {
1147
+ result.catch(() => {});
1148
+ }
1149
+ } catch {
1150
+ // Native storage unavailable — ID will still be stable for this session
1151
+ }
1020
1152
  }
1021
1153
 
1022
1154
  /**
@@ -1047,17 +1179,41 @@ async function ensurePersistentAnonymousId() {
1047
1179
 
1048
1180
  /**
1049
1181
  * Load anonymous ID from persistent storage
1050
- * Call this at app startup for best results
1182
+ * Checks native anonymous storage first, then falls back to native getUserIdentity,
1183
+ * and finally generates a new ID if nothing is persisted.
1051
1184
  */
1052
1185
  async function loadAnonymousId() {
1053
1186
  const nativeModule = getRejourneyNativeModule();
1054
- if (nativeModule && nativeModule.getUserIdentity) {
1187
+
1188
+ // 1. Try native anonymous ID storage
1189
+ if (nativeModule?.getAnonymousId) {
1190
+ try {
1191
+ const stored = await nativeModule.getAnonymousId();
1192
+ if (stored && typeof stored === 'string') return stored;
1193
+ } catch {
1194
+ // Continue to fallbacks
1195
+ }
1196
+ }
1197
+
1198
+ // 2. Backward compatibility fallback for older native modules
1199
+ if (nativeModule?.getUserIdentity) {
1055
1200
  try {
1056
- return (await nativeModule.getUserIdentity()) || generateAnonymousId();
1201
+ const nativeId = await nativeModule.getUserIdentity();
1202
+ if (nativeId && typeof nativeId === 'string') {
1203
+ const normalized = nativeId.trim();
1204
+ // Only migrate legacy anonymous identifiers. Never treat explicit user identities
1205
+ // as anonymous fingerprints, or session correlation becomes unstable.
1206
+ if (normalized.startsWith('anon_')) {
1207
+ _persistAnonymousId(normalized);
1208
+ return normalized;
1209
+ }
1210
+ }
1057
1211
  } catch {
1058
- return generateAnonymousId();
1212
+ // Continue to fallback
1059
1213
  }
1060
1214
  }
1215
+
1216
+ // 3. Generate and persist new ID
1061
1217
  return generateAnonymousId();
1062
1218
  }
1063
1219
 
@@ -1065,7 +1221,13 @@ async function loadAnonymousId() {
1065
1221
  * Set a custom anonymous ID
1066
1222
  */
1067
1223
  function setAnonymousId(id) {
1068
- anonymousId = id;
1224
+ const normalized = (id || '').trim();
1225
+ if (!normalized) {
1226
+ anonymousId = generateAnonymousId();
1227
+ return;
1228
+ }
1229
+ anonymousId = normalized;
1230
+ _persistAnonymousId(normalized);
1069
1231
  }
1070
1232
  var _default = exports.default = {
1071
1233
  init: initAutoTracking,