@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
@@ -142,9 +142,11 @@ export interface AutoTrackingConfig {
142
142
  trackJSErrors?: boolean;
143
143
  trackPromiseRejections?: boolean;
144
144
  trackReactNativeErrors?: boolean;
145
+ trackConsoleLogs?: boolean;
145
146
  collectDeviceInfo?: boolean;
146
147
  maxSessionDurationMs?: number;
147
148
  detectDeadTaps?: boolean;
149
+ autoTrackExpoRouter?: boolean;
148
150
  }
149
151
 
150
152
  let isInitialized = false;
@@ -183,6 +185,7 @@ let originalOnError: OnErrorEventHandler | null = null;
183
185
  let originalOnUnhandledRejection: ((event: PromiseRejectionEvent) => void) | null = null;
184
186
  let originalConsoleError: ((...args: any[]) => void) | null = null;
185
187
  let _promiseRejectionTrackingDisable: (() => void) | null = null;
188
+ const FATAL_ERROR_FLUSH_DELAY_MS = 1200;
186
189
 
187
190
  /**
188
191
  * Initialize auto tracking features
@@ -205,7 +208,9 @@ export function initAutoTracking(
205
208
  trackJSErrors: true,
206
209
  trackPromiseRejections: true,
207
210
  trackReactNativeErrors: true,
211
+ trackConsoleLogs: true,
208
212
  collectDeviceInfo: true,
213
+ autoTrackExpoRouter: true,
209
214
  maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
210
215
  ...trackingConfig,
211
216
  };
@@ -221,6 +226,9 @@ export function initAutoTracking(
221
226
  onErrorCaptured = callbacks.onError || null;
222
227
  onScreenChange = callbacks.onScreen || null;
223
228
  setupErrorTracking();
229
+ if (config.trackConsoleLogs) {
230
+ setupConsoleTracking();
231
+ }
224
232
  setupNavigationTracking();
225
233
  loadAnonymousId().then(id => {
226
234
  anonymousId = id;
@@ -236,11 +244,13 @@ export function cleanupAutoTracking(): void {
236
244
  if (!isInitialized) return;
237
245
 
238
246
  restoreErrorHandlers();
247
+ restoreConsoleHandlers();
239
248
  cleanupNavigationTracking();
240
249
 
241
250
  // Reset state
242
251
  tapHead = 0;
243
252
  tapCount = 0;
253
+ consoleLogCount = 0;
244
254
  metrics = createEmptyMetrics();
245
255
  screensVisited = [];
246
256
  currentScreen = '';
@@ -357,7 +367,7 @@ function setupErrorTracking(): void {
357
367
  /**
358
368
  * Setup React Native ErrorUtils handler
359
369
  *
360
- * CRITICAL FIX: For fatal errors, we delay calling the original handler by 500ms
370
+ * CRITICAL FIX: For fatal errors, we delay calling the original handler briefly
361
371
  * to give the React Native bridge time to flush the logEvent('error') call to the
362
372
  * native TelemetryPipeline. Without this delay, the error event is queued on the
363
373
  * JS→native bridge but the app crashes (via originalErrorHandler) before the bridge
@@ -384,10 +394,10 @@ function setupReactNativeErrorHandler(): void {
384
394
  if (isFatal) {
385
395
  // For fatal errors, delay the original handler so the native bridge
386
396
  // has time to deliver the error event to TelemetryPipeline before
387
- // the app terminates. 500ms is enough for the bridge to flush.
397
+ // the app terminates.
388
398
  setTimeout(() => {
389
399
  originalErrorHandler!(error, isFatal);
390
- }, 500);
400
+ }, FATAL_ERROR_FLUSH_DELAY_MS);
391
401
  } else {
392
402
  originalErrorHandler(error, isFatal);
393
403
  }
@@ -450,7 +460,6 @@ function setupPromiseRejectionHandler(): void {
450
460
 
451
461
  // Strategy 1: RN-specific promise rejection tracking polyfill
452
462
  try {
453
- // eslint-disable-next-line @typescript-eslint/no-var-requires
454
463
  const tracking = require('promise/setimmediate/rejection-tracking');
455
464
  if (tracking && typeof tracking.enable === 'function') {
456
465
  tracking.enable({
@@ -565,8 +574,30 @@ function trackError(error: ErrorEvent): void {
565
574
  metrics.errorCount++;
566
575
  metrics.totalEvents++;
567
576
 
577
+ forwardErrorToNative(error);
578
+
568
579
  if (onErrorCaptured) {
569
- onErrorCaptured(error);
580
+ try {
581
+ onErrorCaptured(error);
582
+ } catch {
583
+ // Ignore callback exceptions so SDK error forwarding keeps working.
584
+ }
585
+ }
586
+ }
587
+
588
+ function forwardErrorToNative(error: ErrorEvent): void {
589
+ try {
590
+ const nativeModule = getRejourneyNativeModule();
591
+ if (!nativeModule || typeof nativeModule.logEvent !== 'function') return;
592
+
593
+ nativeModule.logEvent('error', {
594
+ message: error.message,
595
+ stack: error.stack,
596
+ name: error.name || 'Error',
597
+ timestamp: error.timestamp,
598
+ }).catch(() => { });
599
+ } catch {
600
+ // Ignore native forwarding failures; SDK should never crash app code.
570
601
  }
571
602
  }
572
603
 
@@ -587,11 +618,113 @@ export function captureError(
587
618
  });
588
619
  }
589
620
 
621
+ let originalConsoleLog: ((...args: any[]) => void) | null = null;
622
+ let originalConsoleInfo: ((...args: any[]) => void) | null = null;
623
+ let originalConsoleWarn: ((...args: any[]) => void) | null = null;
624
+
625
+ // Cap console logs to prevent flooding the event pipeline
626
+ const MAX_CONSOLE_LOGS_PER_SESSION = 1000;
627
+ let consoleLogCount = 0;
628
+
629
+ /**
630
+ * Setup console tracking to capture log statements
631
+ */
632
+ function setupConsoleTracking(): void {
633
+ if (typeof console === 'undefined') return;
634
+
635
+ if (!originalConsoleLog) originalConsoleLog = console.log;
636
+ if (!originalConsoleInfo) originalConsoleInfo = console.info;
637
+ if (!originalConsoleWarn) originalConsoleWarn = console.warn;
638
+
639
+ const createConsoleInterceptor = (level: 'log' | 'info' | 'warn' | 'error', originalFn: (...args: any[]) => void) => {
640
+ return (...args: any[]) => {
641
+ try {
642
+ const message = args.map(arg => {
643
+ if (typeof arg === 'string') return arg;
644
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}${arg.stack ? `\n...` : ''}`;
645
+ try {
646
+ return JSON.stringify(arg);
647
+ } catch {
648
+ return String(arg);
649
+ }
650
+ }).join(' ');
651
+
652
+ // Enforce per-session cap and skip React Native unhandled-rejection noise.
653
+ if (
654
+ consoleLogCount < MAX_CONSOLE_LOGS_PER_SESSION &&
655
+ !message.includes('Possible Unhandled Promise Rejection')
656
+ ) {
657
+ consoleLogCount++;
658
+ const nativeModule = getRejourneyNativeModule();
659
+ if (nativeModule) {
660
+ const logEvent = {
661
+ type: 'log',
662
+ timestamp: Date.now(),
663
+ level,
664
+ message: message.length > 2000 ? message.substring(0, 2000) + '...' : message,
665
+ };
666
+ nativeModule.logEvent('log', logEvent).catch(() => { });
667
+ }
668
+ }
669
+ } catch {
670
+ // Ignore any errors during interception
671
+ }
672
+
673
+ if (originalFn) {
674
+ originalFn.apply(console, args);
675
+ }
676
+ };
677
+ };
678
+
679
+ console.log = createConsoleInterceptor('log', originalConsoleLog!);
680
+ console.info = createConsoleInterceptor('info', originalConsoleInfo!);
681
+ console.warn = createConsoleInterceptor('warn', originalConsoleWarn!);
682
+
683
+ const currentConsoleError = console.error;
684
+ if (!originalConsoleError) originalConsoleError = currentConsoleError;
685
+ console.error = createConsoleInterceptor('error', currentConsoleError);
686
+ }
687
+
688
+ /**
689
+ * Restore console standard functions
690
+ */
691
+ function restoreConsoleHandlers(): void {
692
+ if (originalConsoleLog) {
693
+ console.log = originalConsoleLog;
694
+ originalConsoleLog = null;
695
+ }
696
+ if (originalConsoleInfo) {
697
+ console.info = originalConsoleInfo;
698
+ originalConsoleInfo = null;
699
+ }
700
+ if (originalConsoleWarn) {
701
+ console.warn = originalConsoleWarn;
702
+ originalConsoleWarn = null;
703
+ }
704
+ // Note: console.error is restored in restoreErrorHandlers via originalConsoleError
705
+ }
706
+
590
707
  let navigationPollingInterval: ReturnType<typeof setInterval> | null = null;
708
+ /** Interval ID from optional expo-router entry; cleared in cleanupNavigationTracking */
709
+ let expoRouterPollingIntervalId: ReturnType<typeof setInterval> | null = null;
591
710
  let lastDetectedScreen = '';
592
711
  let navigationSetupDone = false;
593
- let navigationPollingErrors = 0;
594
- const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
712
+
713
+ /**
714
+ * Register the polling interval from the optional expo-router entry so we can clear it on cleanup.
715
+ * Used by src/expoRouterTracking.ts (only loaded when app imports '@rejourneyco/react-native/expo-router').
716
+ */
717
+ export function setExpoRouterPollingInterval(id: ReturnType<typeof setInterval> | null): void {
718
+ expoRouterPollingIntervalId = id;
719
+ }
720
+
721
+ /**
722
+ * Check if Expo Router auto-tracking is enabled in the current configuration.
723
+ * Used by src/expoRouterTracking.ts.
724
+ */
725
+ export function isExpoRouterTrackingEnabled(): boolean {
726
+ return config.autoTrackExpoRouter !== false;
727
+ }
595
728
 
596
729
  /**
597
730
  * Track a navigation state change from React Navigation.
@@ -688,101 +821,97 @@ export function useNavigationTracking() {
688
821
  }
689
822
 
690
823
  /**
691
- * Setup automatic Expo Router tracking
692
- *
693
- * For Expo apps using expo-router - works automatically.
694
- * For bare React Native apps - use trackNavigationState instead.
824
+ * Setup automatic navigation tracking.
825
+ *
826
+ * Expo Router: not set up here to avoid pulling expo-router into the main bundle
827
+ * (Metro resolves require() at build time, which causes "Requiring unknown module"
828
+ * in apps that use Expo + react-navigation without expo-router). If you use
829
+ * expo-router, add: import '@rejourneyco/react-native/expo-router';
830
+ *
831
+ * For React Navigation (non–expo-router), use trackNavigationState() on your
832
+ * NavigationContainer's onStateChange.
695
833
  */
696
834
  function setupNavigationTracking(): void {
697
835
  if (navigationSetupDone) return;
698
836
  navigationSetupDone = true;
699
837
 
700
- if (__DEV__) {
701
- logger.debug('Setting up navigation tracking...');
838
+ // Auto-detect expo-router and set up screen tracking if available.
839
+ // This is safe: if expo-router isn't installed, the require fails silently.
840
+ // We defer slightly so the router has time to initialize after JS bundle load.
841
+ if (config.autoTrackExpoRouter !== false) {
842
+ tryAutoSetupExpoRouter();
702
843
  }
844
+ }
703
845
 
704
- let attempts = 0;
705
- const maxAttempts = 5;
706
-
707
- const trySetup = () => {
708
- attempts++;
709
- if (__DEV__) {
710
- logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
711
- }
712
-
713
- const success = trySetupExpoRouter();
846
+ /**
847
+ * Attempt to auto-detect and set up expo-router screen tracking.
848
+ * Uses a retry mechanism because the router may not be ready immediately
849
+ * after JS bundle load.
850
+ */
851
+ function tryAutoSetupExpoRouter(attempt: number = 0, maxAttempts: number = 5): void {
852
+ const delay = 200 * (attempt + 1); // 200, 400, 600, 800, 1000ms
714
853
 
715
- if (success) {
716
- if (__DEV__) {
717
- logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
718
- }
719
- } else if (attempts < maxAttempts) {
720
- const delay = 200 * attempts;
721
- if (__DEV__) {
722
- logger.debug('Expo Router not ready, retrying in', delay, 'ms');
854
+ setTimeout(() => {
855
+ try {
856
+ // Dynamic require wrapped in a variable to prevent Metro from statically resolving it
857
+ const EXPO_ROUTER = 'expo-router';
858
+ const expoRouter = require(EXPO_ROUTER);
859
+
860
+ if (!expoRouter?.router) {
861
+ // expo-router exists but router not ready yet retry
862
+ if (attempt < maxAttempts - 1) {
863
+ tryAutoSetupExpoRouter(attempt + 1, maxAttempts);
864
+ }
865
+ return;
723
866
  }
724
- setTimeout(trySetup, delay);
725
- } else {
726
- if (__DEV__) {
727
- logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
728
- logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
867
+
868
+ // Router is ready — set up the polling-based screen tracker
869
+ setupExpoRouterPolling(expoRouter.router);
870
+ } catch {
871
+ // expo-router not installed — this is fine, just means the app
872
+ // uses bare React Navigation or no navigation at all.
873
+ if (__DEV__ && attempt === 0) {
874
+ logger.debug('Expo Router not detected, skipping auto screen tracking. Use trackNavigationState() for React Navigation.');
729
875
  }
730
876
  }
731
- };
732
-
733
- setTimeout(trySetup, 200);
877
+ }, delay);
734
878
  }
735
879
 
736
880
  /**
737
- * Set up Expo Router auto-tracking by polling the internal router store
738
- *
739
- * Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
740
- */
741
- function trySetupExpoRouter(): boolean {
742
- try {
743
- const expoRouter = require('expo-router');
744
- const router = expoRouter.router;
745
-
746
- if (!router) {
747
- if (__DEV__) {
748
- logger.debug('Expo Router: router object not found');
749
- }
750
- return false;
751
- }
881
+ * Poll expo-router state for screen changes.
882
+ * Inlined from expoRouterTracking.ts so no separate import is needed.
883
+ */
884
+ function setupExpoRouterPolling(router: any): void {
885
+ // Guard against double-setup (core auto-detection + legacy expoRouterTracking.ts import)
886
+ if (expoRouterPollingIntervalId != null) return;
752
887
 
753
- if (__DEV__) {
754
- logger.debug('Expo Router: Setting up navigation tracking');
755
- }
888
+ const MAX_POLLING_ERRORS = 10;
889
+ let pollingErrors = 0;
756
890
 
891
+ try {
757
892
  const { normalizeScreenName, getScreenNameFromPath } = require('./navigation');
758
893
 
759
- navigationPollingInterval = setInterval(() => {
894
+ const intervalId = setInterval(() => {
760
895
  try {
761
- let state = null;
762
- let stateSource = '';
896
+ let state: any = null;
897
+
763
898
  if (typeof router.getState === 'function') {
764
899
  state = router.getState();
765
- stateSource = 'router.getState()';
766
- } else if ((router as any).rootState) {
767
- state = (router as any).rootState;
768
- stateSource = 'router.rootState';
900
+ } else if (router.rootState) {
901
+ state = router.rootState;
769
902
  }
770
903
 
771
904
  if (!state) {
772
905
  try {
773
- const storeModule = require('expo-router/build/global-state/router-store');
906
+ const STORE_PATH = 'expo-router/build/global-state/router-store';
907
+ const storeModule = require(STORE_PATH);
774
908
  if (storeModule?.store) {
775
909
  state = storeModule.store.state;
776
- if (state) stateSource = 'store.state';
777
-
778
910
  if (!state && storeModule.store.navigationRef?.current) {
779
911
  state = storeModule.store.navigationRef.current.getRootState?.();
780
- if (state) stateSource = 'navigationRef.getRootState()';
781
912
  }
782
-
783
913
  if (!state) {
784
914
  state = storeModule.store.rootState || storeModule.store.initialState;
785
- if (state) stateSource = 'store.rootState/initialState';
786
915
  }
787
916
  }
788
917
  } catch {
@@ -792,10 +921,10 @@ function trySetupExpoRouter(): boolean {
792
921
 
793
922
  if (!state) {
794
923
  try {
795
- const imperative = require('expo-router/build/imperative-api');
924
+ const IMPERATIVE_PATH = 'expo-router/build/imperative-api';
925
+ const imperative = require(IMPERATIVE_PATH);
796
926
  if (imperative?.router) {
797
927
  state = imperative.router.getState?.();
798
- if (state) stateSource = 'imperative-api';
799
928
  }
800
929
  } catch {
801
930
  // Ignore
@@ -803,55 +932,45 @@ function trySetupExpoRouter(): boolean {
803
932
  }
804
933
 
805
934
  if (state) {
806
- navigationPollingErrors = 0;
807
- navigationPollingErrors = 0;
808
- const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
935
+ pollingErrors = 0;
936
+ const screenName = extractScreenNameFromRouterState(
937
+ state,
938
+ getScreenNameFromPath,
939
+ normalizeScreenName
940
+ );
809
941
  if (screenName && screenName !== lastDetectedScreen) {
810
- if (__DEV__) {
811
- logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
812
- }
813
942
  lastDetectedScreen = screenName;
814
943
  trackScreen(screenName);
815
944
  }
816
945
  } else {
817
- navigationPollingErrors++;
818
- if (__DEV__ && navigationPollingErrors === 1) {
819
- logger.debug('Expo Router: Could not get navigation state');
820
- }
821
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
822
- cleanupNavigationTracking();
946
+ pollingErrors++;
947
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
948
+ clearInterval(intervalId);
949
+ expoRouterPollingIntervalId = null;
823
950
  }
824
951
  }
825
- } catch (e) {
826
- navigationPollingErrors++;
827
- if (__DEV__ && navigationPollingErrors === 1) {
828
- logger.debug('Expo Router polling error:', e);
829
- }
830
- if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
831
- cleanupNavigationTracking();
952
+ } catch {
953
+ pollingErrors++;
954
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
955
+ clearInterval(intervalId);
956
+ expoRouterPollingIntervalId = null;
832
957
  }
833
958
  }
834
959
  }, 500);
835
960
 
836
- return true;
837
- } catch (e) {
838
- if (__DEV__) {
839
- logger.debug('Expo Router not available:', e);
840
- }
841
- return false;
961
+ expoRouterPollingIntervalId = intervalId;
962
+ } catch {
963
+ // navigation module not available — ignore
842
964
  }
843
965
  }
844
966
 
845
967
  /**
846
- * Extract screen name from Expo Router navigation state
847
- *
848
- * Handles complex nested structures like Drawer → Tabs → Stack
849
- * by recursively accumulating segments from each navigation level.
968
+ * Extract the active screen name from expo-router navigation state.
850
969
  */
851
970
  function extractScreenNameFromRouterState(
852
971
  state: any,
853
- getScreenNameFromPath: (path: string, segments: string[]) => string,
854
- normalizeScreenName: (name: string) => string,
972
+ getScreenNameFromPathFn: (path: string, segments: string[]) => string,
973
+ normalizeScreenNameFn: (name: string) => string,
855
974
  accumulatedSegments: string[] = []
856
975
  ): string | null {
857
976
  if (!state?.routes) return null;
@@ -864,13 +983,13 @@ function extractScreenNameFromRouterState(
864
983
  if (route.state) {
865
984
  return extractScreenNameFromRouterState(
866
985
  route.state,
867
- getScreenNameFromPath,
868
- normalizeScreenName,
986
+ getScreenNameFromPathFn,
987
+ normalizeScreenNameFn,
869
988
  newSegments
870
989
  );
871
990
  }
872
991
 
873
- const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
992
+ const cleanSegments = newSegments.filter((s) => !s.startsWith('(') && !s.endsWith(')'));
874
993
 
875
994
  if (cleanSegments.length === 0) {
876
995
  for (let i = newSegments.length - 1; i >= 0; i--) {
@@ -883,7 +1002,7 @@ function extractScreenNameFromRouterState(
883
1002
  }
884
1003
 
885
1004
  const pathname = '/' + cleanSegments.join('/');
886
- return getScreenNameFromPath(pathname, newSegments);
1005
+ return getScreenNameFromPathFn(pathname, newSegments);
887
1006
  }
888
1007
 
889
1008
  /**
@@ -894,9 +1013,12 @@ function cleanupNavigationTracking(): void {
894
1013
  clearInterval(navigationPollingInterval);
895
1014
  navigationPollingInterval = null;
896
1015
  }
1016
+ if (expoRouterPollingIntervalId != null) {
1017
+ clearInterval(expoRouterPollingIntervalId);
1018
+ expoRouterPollingIntervalId = null;
1019
+ }
897
1020
  navigationSetupDone = false;
898
1021
  lastDetectedScreen = '';
899
- navigationPollingErrors = 0;
900
1022
  }
901
1023
 
902
1024
  /**
@@ -1174,7 +1296,27 @@ export async function collectDeviceInfo(): Promise<DeviceInfo> {
1174
1296
  function generateAnonymousId(): string {
1175
1297
  const timestamp = Date.now().toString(36);
1176
1298
  const random = Math.random().toString(36).substring(2, 15);
1177
- return `anon_${timestamp}_${random}`;
1299
+ const id = `anon_${timestamp}_${random}`;
1300
+ // Persist so the same ID survives app restarts
1301
+ _persistAnonymousId(id);
1302
+ return id;
1303
+ }
1304
+
1305
+ /**
1306
+ * Best-effort async persist of anonymous ID to native storage
1307
+ */
1308
+ function _persistAnonymousId(id: string): void {
1309
+ const nativeModule = getRejourneyNativeModule();
1310
+ if (!nativeModule?.setAnonymousId) return;
1311
+
1312
+ try {
1313
+ const result = nativeModule.setAnonymousId(id);
1314
+ if (result && typeof result.catch === 'function') {
1315
+ result.catch(() => { });
1316
+ }
1317
+ } catch {
1318
+ // Native storage unavailable — ID will still be stable for this session
1319
+ }
1178
1320
  }
1179
1321
 
1180
1322
  /**
@@ -1205,17 +1347,41 @@ export async function ensurePersistentAnonymousId(): Promise<string> {
1205
1347
 
1206
1348
  /**
1207
1349
  * Load anonymous ID from persistent storage
1208
- * Call this at app startup for best results
1350
+ * Checks native anonymous storage first, then falls back to native getUserIdentity,
1351
+ * and finally generates a new ID if nothing is persisted.
1209
1352
  */
1210
1353
  export async function loadAnonymousId(): Promise<string> {
1211
1354
  const nativeModule = getRejourneyNativeModule();
1212
- if (nativeModule && nativeModule.getUserIdentity) {
1355
+
1356
+ // 1. Try native anonymous ID storage
1357
+ if (nativeModule?.getAnonymousId) {
1358
+ try {
1359
+ const stored = await nativeModule.getAnonymousId();
1360
+ if (stored && typeof stored === 'string') return stored;
1361
+ } catch {
1362
+ // Continue to fallbacks
1363
+ }
1364
+ }
1365
+
1366
+ // 2. Backward compatibility fallback for older native modules
1367
+ if (nativeModule?.getUserIdentity) {
1213
1368
  try {
1214
- return await nativeModule.getUserIdentity() || generateAnonymousId();
1369
+ const nativeId = await nativeModule.getUserIdentity();
1370
+ if (nativeId && typeof nativeId === 'string') {
1371
+ const normalized = nativeId.trim();
1372
+ // Only migrate legacy anonymous identifiers. Never treat explicit user identities
1373
+ // as anonymous fingerprints, or session correlation becomes unstable.
1374
+ if (normalized.startsWith('anon_')) {
1375
+ _persistAnonymousId(normalized);
1376
+ return normalized;
1377
+ }
1378
+ }
1215
1379
  } catch {
1216
- return generateAnonymousId();
1380
+ // Continue to fallback
1217
1381
  }
1218
1382
  }
1383
+
1384
+ // 3. Generate and persist new ID
1219
1385
  return generateAnonymousId();
1220
1386
  }
1221
1387
 
@@ -1223,7 +1389,13 @@ export async function loadAnonymousId(): Promise<string> {
1223
1389
  * Set a custom anonymous ID
1224
1390
  */
1225
1391
  export function setAnonymousId(id: string): void {
1226
- anonymousId = id;
1392
+ const normalized = (id || '').trim();
1393
+ if (!normalized) {
1394
+ anonymousId = generateAnonymousId();
1395
+ return;
1396
+ }
1397
+ anonymousId = normalized;
1398
+ _persistAnonymousId(normalized);
1227
1399
  }
1228
1400
 
1229
1401
  export default {