@luciq/react-native 19.4.0 → 19.6.0

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 (58) hide show
  1. package/.claude/agents/codebase-analyzer.md +33 -0
  2. package/.claude/agents/codebase-locator.md +42 -0
  3. package/.claude/agents/codebase-pattern-finder.md +40 -0
  4. package/.claude/commands/apply-pr-reviews.md +253 -0
  5. package/.claude/commands/create-jira-workitem.md +27 -0
  6. package/.claude/commands/create-pr.md +138 -0
  7. package/.claude/commands/create-public-release-notes.md +145 -0
  8. package/.claude/commands/create-rca.md +286 -0
  9. package/.claude/commands/debug-sdk.md +66 -0
  10. package/.claude/commands/describe-pr.md +40 -0
  11. package/.claude/commands/new-api.md +60 -0
  12. package/.claude/commands/new-feature.md +75 -0
  13. package/.claude/commands/pr-review.md +85 -0
  14. package/.claude/commands/research-codebase.md +41 -0
  15. package/.claude/commands/review.md +73 -0
  16. package/.claude/memory/MEMORY.md +1 -0
  17. package/.claude/memory/feedback_pr_title_format.md +10 -0
  18. package/.claude/rules/react-native-typescript.md +46 -0
  19. package/CHANGELOG.md +12 -0
  20. package/CLAUDE.md +125 -0
  21. package/android/native.gradle +1 -1
  22. package/android/src/main/java/ai/luciq/reactlibrary/LuciqScreenLoadingFrameTracker.java +88 -0
  23. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqAPMModule.java +184 -10
  24. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +5 -3
  25. package/dist/components/LuciqCaptureScreenLoading.d.ts +8 -0
  26. package/dist/components/LuciqCaptureScreenLoading.js +154 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/modules/APM.d.ts +19 -0
  30. package/dist/modules/APM.js +38 -0
  31. package/dist/modules/Luciq.d.ts +1 -1
  32. package/dist/modules/Luciq.js +169 -11
  33. package/dist/modules/apm/ScreenLoadingManager.d.ts +99 -0
  34. package/dist/modules/apm/ScreenLoadingManager.js +296 -0
  35. package/dist/native/NativeAPM.d.ts +9 -0
  36. package/dist/native/NativeLuciq.d.ts +1 -1
  37. package/dist/utils/LuciqUtils.d.ts +25 -0
  38. package/dist/utils/LuciqUtils.js +44 -0
  39. package/dist/utils/RouteMatcher.d.ts +30 -0
  40. package/dist/utils/RouteMatcher.js +67 -0
  41. package/ios/RNLuciq/LuciqAPMBridge.m +82 -0
  42. package/ios/RNLuciq/LuciqReactBridge.m +1 -1
  43. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.h +11 -0
  44. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.m +121 -0
  45. package/ios/RNLuciq/Util/LCQAPM+PrivateAPIs.h +14 -0
  46. package/ios/native.rb +1 -1
  47. package/package.json +4 -1
  48. package/scripts/get-github-app-token.sh +70 -0
  49. package/scripts/notify-github.sh +17 -8
  50. package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
  51. package/src/index.ts +4 -0
  52. package/src/modules/APM.ts +42 -0
  53. package/src/modules/Luciq.ts +197 -11
  54. package/src/modules/apm/ScreenLoadingManager.ts +364 -0
  55. package/src/native/NativeAPM.ts +22 -0
  56. package/src/native/NativeLuciq.ts +1 -1
  57. package/src/utils/LuciqUtils.ts +49 -0
  58. package/src/utils/RouteMatcher.ts +83 -0
@@ -0,0 +1,154 @@
1
+ import React, { useState, useRef, useEffect, useLayoutEffect, useContext } from 'react';
2
+ import { View } from 'react-native';
3
+ import { ScreenLoadingManager } from '../modules/apm/ScreenLoadingManager';
4
+ import { Logger } from '../utils/logger';
5
+ import { nowMicros, toEpochMicros } from '../utils/LuciqUtils';
6
+ // Context to handle nested components
7
+ const ScreenLoadingContext = React.createContext(false);
8
+ export function LuciqCaptureScreenLoading(props) {
9
+ const { screenName, record, onMeasured, onLayout, children, ...viewProps } = props;
10
+ const isNested = useContext(ScreenLoadingContext);
11
+ // Refs for timestamps (these don't need to trigger re-renders)
12
+ const constructorTimestampRef = useRef(nowMicros()); // microseconds
13
+ const renderStartTimestampRef = useRef(undefined);
14
+ const renderEndTimestampRef = useRef(undefined);
15
+ const mountTimestampRef = useRef(undefined);
16
+ // Guards to ensure single execution
17
+ const initializedRef = useRef(false);
18
+ const hasFirstRenderCompletedRef = useRef(false);
19
+ const attributesRecordedRef = useRef(false);
20
+ const initialSpanIdRef = useRef(null);
21
+ // Capture render start timestamp ONLY on first render
22
+ if (!hasFirstRenderCompletedRef.current) {
23
+ renderStartTimestampRef.current = nowMicros();
24
+ }
25
+ // Initialize span - runs once like constructor (lazy initialization)
26
+ if (!initializedRef.current) {
27
+ initializedRef.current = true;
28
+ // Initialize span if conditions are met
29
+ try {
30
+ if (record !== false && ScreenLoadingManager.isFeatureEnabled()) {
31
+ const span = ScreenLoadingManager.createSpan(screenName, true, constructorTimestampRef.current);
32
+ if (span) {
33
+ initialSpanIdRef.current = span.spanId;
34
+ Logger.log(`[LuciqScreenLoading] Span ${span.spanId} created in constructor`);
35
+ }
36
+ }
37
+ }
38
+ catch (error) {
39
+ Logger.error('[LuciqScreenLoading] Failed to create span:', error);
40
+ }
41
+ }
42
+ const [spanId, setSpanId] = useState(initialSpanIdRef.current);
43
+ const [isMeasured, setIsMeasured] = useState(false);
44
+ // Ref to avoid stale closure in useLayoutEffect
45
+ const onMeasuredRef = useRef(onMeasured);
46
+ useEffect(() => {
47
+ onMeasuredRef.current = onMeasured;
48
+ }, [onMeasured]);
49
+ // Refs to track latest values for cleanup (componentWillUnmount)
50
+ const spanIdRef = useRef(spanId);
51
+ const isMeasuredRef = useRef(isMeasured);
52
+ // Keep refs in sync with state
53
+ useEffect(() => {
54
+ spanIdRef.current = spanId;
55
+ }, [spanId]);
56
+ useEffect(() => {
57
+ isMeasuredRef.current = isMeasured;
58
+ }, [isMeasured]);
59
+ // Handle nested component detection
60
+ useEffect(() => {
61
+ // Check if we're nested and should ignore this component
62
+ if (isNested && initialSpanIdRef.current) {
63
+ Logger.log(`[LuciqScreenLoading] Nested component detected, ignoring span ${initialSpanIdRef.current}`);
64
+ // Cancel the span
65
+ setSpanId(null);
66
+ }
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, []); // Empty deps = componentDidMount
69
+ // Record lifecycle timestamps after first render completes (synchronous)
70
+ // useLayoutEffect fires synchronously after DOM mutations but before browser paint
71
+ useLayoutEffect(() => {
72
+ // Skip if no span, already recorded, or nested
73
+ if (!spanId || attributesRecordedRef.current || isNested) {
74
+ return;
75
+ }
76
+ // endSpan is async (native frame timestamp fetch), fire-and-forget from useLayoutEffect
77
+ ScreenLoadingManager.endSpan(spanId)
78
+ .then(() => {
79
+ const completedSpan = ScreenLoadingManager.getActiveSpan(spanId);
80
+ if (completedSpan?.ttid && onMeasuredRef.current) {
81
+ onMeasuredRef.current(completedSpan.ttid / 1000);
82
+ }
83
+ })
84
+ .catch((error) => {
85
+ Logger.warn('[LuciqScreenLoading] Failed to end span:', error);
86
+ });
87
+ attributesRecordedRef.current = true;
88
+ mountTimestampRef.current = nowMicros();
89
+ try {
90
+ // Record all timestamps
91
+ ScreenLoadingManager.addSpanAttribute(spanId, 'cnst_mus_st', toEpochMicros(constructorTimestampRef.current));
92
+ if (renderStartTimestampRef.current) {
93
+ ScreenLoadingManager.addSpanAttribute(spanId, 'rnd_mus_st', toEpochMicros(renderStartTimestampRef.current));
94
+ }
95
+ ScreenLoadingManager.addSpanAttribute(spanId, 'mnt_mus_st', toEpochMicros(mountTimestampRef.current));
96
+ // Record all durations
97
+ if (renderStartTimestampRef.current) {
98
+ // Constructor duration: time from component init to first render start
99
+ const constructorDuration = renderStartTimestampRef.current - constructorTimestampRef.current;
100
+ ScreenLoadingManager.addSpanAttribute(spanId, 'cnst_mus', constructorDuration);
101
+ }
102
+ if (renderEndTimestampRef.current && renderStartTimestampRef.current) {
103
+ // Render duration: time spent creating JSX
104
+ const renderDuration = renderEndTimestampRef.current - renderStartTimestampRef.current;
105
+ ScreenLoadingManager.addSpanAttribute(spanId, 'rnd_mus', renderDuration);
106
+ }
107
+ if (mountTimestampRef.current && renderEndTimestampRef.current) {
108
+ // Mount duration: time from render complete to effect execution
109
+ const mountDuration = mountTimestampRef.current - renderEndTimestampRef.current;
110
+ ScreenLoadingManager.addSpanAttribute(spanId, 'mnt_mus', mountDuration);
111
+ }
112
+ Logger.log(`[LuciqScreenLoading] Lifecycle measurements for span ${spanId}:`, {
113
+ constructor_us: renderStartTimestampRef.current
114
+ ? renderStartTimestampRef.current - constructorTimestampRef.current
115
+ : undefined,
116
+ render_us: renderEndTimestampRef.current && renderStartTimestampRef.current
117
+ ? renderEndTimestampRef.current - renderStartTimestampRef.current
118
+ : undefined,
119
+ mount_us: mountTimestampRef.current && renderEndTimestampRef.current
120
+ ? mountTimestampRef.current - renderEndTimestampRef.current
121
+ : undefined,
122
+ });
123
+ }
124
+ catch (error) {
125
+ Logger.error(`[LuciqScreenLoading] Failed to record attributes for span ${spanId}:`, error);
126
+ }
127
+ // End the span — mark as measured synchronously to guard against unmount race
128
+ setIsMeasured(true);
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [spanId]); // Run when spanId is set
131
+ // componentWillUnmount equivalent
132
+ useEffect(() => {
133
+ return () => {
134
+ // Cleanup on unmount if not measured
135
+ if (spanIdRef.current && !isMeasuredRef.current) {
136
+ ScreenLoadingManager.endSpan(spanIdRef.current).catch((error) => {
137
+ Logger.warn('[LuciqScreenLoading] Failed to end span on unmount:', error);
138
+ });
139
+ }
140
+ };
141
+ }, []); // Empty deps = only runs cleanup on unmount
142
+ // Create the JSX result
143
+ const result = (<ScreenLoadingContext.Provider value={spanId !== null}>
144
+ <View {...viewProps} onLayout={onLayout}>
145
+ {children}
146
+ </View>
147
+ </ScreenLoadingContext.Provider>);
148
+ // Capture render end timestamp ONLY on first render (after JSX creation)
149
+ if (!hasFirstRenderCompletedRef.current) {
150
+ renderEndTimestampRef.current = nowMicros();
151
+ hasFirstRenderCompletedRef.current = true;
152
+ }
153
+ return result;
154
+ }
package/dist/index.d.ts CHANGED
@@ -18,4 +18,6 @@ import type { SessionMetadata } from './models/SessionMetadata';
18
18
  export * from './utils/Enums';
19
19
  export { Report, CustomSpan, APM, BugReporting, CrashReporting, FeatureRequests, NetworkLogger, SessionReplay, Replies, Surveys, ProactiveReportingConfigOptions, createProactiveReportingConfig, };
20
20
  export type { LuciqConfig, Survey, NetworkData, NetworkDataObfuscationHandler, SessionMetadata, ThemeConfig, };
21
+ export { LuciqCaptureScreenLoading } from './components/LuciqCaptureScreenLoading';
22
+ export type { LuciqScreenLoadingProps } from './components/LuciqCaptureScreenLoading';
21
23
  export default Luciq;
package/dist/index.js CHANGED
@@ -13,4 +13,6 @@ import * as Surveys from './modules/Surveys';
13
13
  import * as SessionReplay from './modules/SessionReplay';
14
14
  export * from './utils/Enums';
15
15
  export { Report, CustomSpan, APM, BugReporting, CrashReporting, FeatureRequests, NetworkLogger, SessionReplay, Replies, Surveys, createProactiveReportingConfig, };
16
+ // Screen Loading Component
17
+ export { LuciqCaptureScreenLoading } from './components/LuciqCaptureScreenLoading';
16
18
  export default Luciq;
@@ -138,3 +138,22 @@ export declare const startCustomSpan: (name: string) => Promise<CustomSpan | nul
138
138
  * ```
139
139
  */
140
140
  export declare const addCompletedCustomSpan: (name: string, startDate: Date, endDate: Date) => Promise<void>;
141
+ /**
142
+ * Enables or disables Screen Loading feature
143
+ * @param isEnabled
144
+ */
145
+ export declare const setScreenLoadingEnabled: (isEnabled: boolean) => void;
146
+ /**
147
+ * Extends the currently running screen loading trace with a new end timestamp.
148
+ */
149
+ export declare const endScreenLoading: () => void;
150
+ /**
151
+ * Exclude specific routes from automatic screen loading measurement
152
+ * @param routes Array of route names to exclude
153
+ */
154
+ export declare function excludeScreenLoadingRoutes(routes: string[]): void;
155
+ /**
156
+ * Include previously excluded routes back into screen loading measurement
157
+ * @param routes Array of route names to include (or empty to clear all exclusions)
158
+ */
159
+ export declare function includeScreenLoadingRoutes(routes?: string[]): void;
@@ -2,6 +2,12 @@ import { Platform } from 'react-native';
2
2
  import { NativeAPM } from '../native/NativeAPM';
3
3
  import { NativeLuciq } from '../native/NativeLuciq';
4
4
  import { startCustomSpan as startCustomSpanInternal, addCompletedCustomSpan as addCompletedCustomSpanInternal, } from '../utils/CustomSpansManager';
5
+ import { ScreenLoadingManager } from './apm/ScreenLoadingManager';
6
+ import { Logger } from '../utils/logger';
7
+ // Initialize Screen Loading on module load
8
+ ScreenLoadingManager.initialize().catch((error) => {
9
+ Logger.error('[APM] Failed to initialize Screen Loading:', error);
10
+ });
5
11
  /**
6
12
  * Enables or disables APM
7
13
  * @param isEnabled
@@ -171,3 +177,35 @@ export const startCustomSpan = async (name) => {
171
177
  export const addCompletedCustomSpan = async (name, startDate, endDate) => {
172
178
  return addCompletedCustomSpanInternal(name, startDate, endDate);
173
179
  };
180
+ /**
181
+ * Enables or disables Screen Loading feature
182
+ * @param isEnabled
183
+ */
184
+ export const setScreenLoadingEnabled = (isEnabled) => {
185
+ try {
186
+ NativeAPM.setScreenLoadingEnabled(isEnabled);
187
+ }
188
+ catch (error) {
189
+ Logger.error('[APM] Failed to set screen loading enabled:', error);
190
+ }
191
+ };
192
+ /**
193
+ * Extends the currently running screen loading trace with a new end timestamp.
194
+ */
195
+ export const endScreenLoading = () => {
196
+ ScreenLoadingManager.endScreenLoading();
197
+ };
198
+ /**
199
+ * Exclude specific routes from automatic screen loading measurement
200
+ * @param routes Array of route names to exclude
201
+ */
202
+ export function excludeScreenLoadingRoutes(routes) {
203
+ ScreenLoadingManager.excludeRoutes(routes);
204
+ }
205
+ /**
206
+ * Include previously excluded routes back into screen loading measurement
207
+ * @param routes Array of route names to include (or empty to clear all exclusions)
208
+ */
209
+ export function includeScreenLoadingRoutes(routes) {
210
+ ScreenLoadingManager.includeRoutes(routes);
211
+ }
@@ -286,7 +286,7 @@ export declare const onStateChange: (state?: NavigationStateV5) => void;
286
286
  * @param navigationRef a refrence of a navigation container
287
287
  *
288
288
  */
289
- export declare const setNavigationListener: (navigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>) => () => void;
289
+ export declare const setNavigationListener: (navigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>) => void;
290
290
  export declare const reportScreenChange: (screenName: string) => void;
291
291
  /**
292
292
  * Add feature flags to the next report.
@@ -6,6 +6,7 @@ import { LogLevel, NetworkInterceptionMode, ReproStepsMode, StringKey, } from '.
6
6
  import LuciqUtils, { checkNetworkRequestHandlers, resetNativeObfuscationListener, setApmNetworkFlagsIfChanged, stringifyIfNotString, } from '../utils/LuciqUtils';
7
7
  import * as NetworkLogger from './NetworkLogger';
8
8
  import { captureUnhandledRejections } from '../utils/UnhandledRejectionTracking';
9
+ import { ScreenLoadingManager } from './apm/ScreenLoadingManager';
9
10
  import { addAppStateListener } from '../utils/AppStatesHandler';
10
11
  import { NativeNetworkLogger } from '../native/NativeNetworkLogger';
11
12
  import LuciqConstants from '../utils/LuciqConstants';
@@ -19,6 +20,12 @@ let _currentAppState = AppState.currentState;
19
20
  let isNativeInterceptionFeatureEnabled = false; // Checks the value of "cp_native_interception_enabled" backend flag.
20
21
  let hasAPMNetworkPlugin = false; // Android only: checks if the APM plugin is installed.
21
22
  let shouldEnableNativeInterception = false; // For Android: used to disable APM logging inside reportNetworkLog() -> NativeAPM.networkLogAndroid(), For iOS: used to control native interception (true == enabled , false == disabled)
23
+ // Screen Loading tracking variables
24
+ let _navigationRef = null;
25
+ let _currentRoute = null;
26
+ let _activeNavigationSpanId = null;
27
+ let _stateChangeTimeout;
28
+ const STATE_CHANGE_TIMEOUT_MS = 2000; // Safety timeout if state never changes
22
29
  /**
23
30
  * Enables or disables Luciq functionality.
24
31
  * @param isEnabled A boolean to enable/disable Luciq.
@@ -78,7 +85,7 @@ export const init = (config) => {
78
85
  reportCurrentViewForAndroid(firstScreen);
79
86
  setTimeout(() => {
80
87
  if (_currentScreen === firstScreen) {
81
- NativeLuciq.reportScreenChange(firstScreen);
88
+ NativeLuciq.reportScreenChange(firstScreen, null);
82
89
  _currentScreen = null;
83
90
  }
84
91
  }, 1000);
@@ -115,12 +122,14 @@ export const setWebViewUserInteractionsTrackingEnabled = (isEnabled) => {
115
122
  * Handles app state changes and updates APM network flags if necessary.
116
123
  */
117
124
  const handleAppStateChange = async (nextAppState, config) => {
118
- // Checks if the app has come to the foreground
125
+ // Checks if the app has come to the foreground
119
126
  if (['inactive', 'background'].includes(_currentAppState) && nextAppState === 'active') {
120
127
  const isUpdated = await fetchApmNetworkFlags();
121
128
  if (isUpdated) {
122
129
  refreshAPMNetworkConfigs(config);
123
130
  }
131
+ // Refresh screen loading flags from native
132
+ await ScreenLoadingManager.refreshFlags();
124
133
  }
125
134
  _currentAppState = nextAppState;
126
135
  };
@@ -646,21 +655,150 @@ export const onReportSubmitHandler = (handler) => {
646
655
  });
647
656
  NativeLuciq.setPreSendingHandler(handler);
648
657
  };
658
+ /**
659
+ * Helper to clear the state change timeout
660
+ */
661
+ const _clearStateChangeTimeout = () => {
662
+ if (_stateChangeTimeout) {
663
+ clearTimeout(_stateChangeTimeout);
664
+ _stateChangeTimeout = undefined;
665
+ }
666
+ };
667
+ /**
668
+ * Handles React Navigation's __unsafe_action__ event
669
+ * This fires WHEN a navigation action is dispatched (the start of navigation)
670
+ */
671
+ const _onNavigationAction = (event) => {
672
+ // Check for noop actions that shouldn't create spans
673
+ if (event?.data?.noop) {
674
+ Logger.log('[ScreenLoading] Navigation action is a noop, not starting span');
675
+ return;
676
+ }
677
+ // Skip non-navigation actions (like SET_PARAMS, OPEN_DRAWER, etc.)
678
+ const actionType = event?.data?.action?.type;
679
+ if (actionType &&
680
+ ['SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'].includes(actionType)) {
681
+ Logger.log(`[ScreenLoading] Skipping non-navigation action: ${actionType}`);
682
+ return;
683
+ }
684
+ // If there's an existing active span, it means navigation was interrupted
685
+ // Discard the previous span as it never completed
686
+ if (_activeNavigationSpanId) {
687
+ Logger.log('[ScreenLoading] Discarding incomplete previous navigation span');
688
+ // Mark the span as cancelled/error since state change never occurred
689
+ const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
690
+ if (span) {
691
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId);
692
+ }
693
+ _activeNavigationSpanId = null;
694
+ _clearStateChangeTimeout();
695
+ }
696
+ // Create a new span for this navigation action
697
+ // We don't know the destination screen yet, so use a placeholder name
698
+ if (ScreenLoadingManager.isFeatureEnabled()) {
699
+ const span = ScreenLoadingManager.createSpan('NavigationPending', false);
700
+ if (span) {
701
+ _activeNavigationSpanId = span.spanId;
702
+ Logger.log(`[ScreenLoading] Started span ${span.spanId} on navigation dispatch`);
703
+ // Set a safety timeout to discard the span if state never changes
704
+ // This prevents memory leaks from incomplete navigations
705
+ _stateChangeTimeout = setTimeout(() => {
706
+ if (_activeNavigationSpanId === span.spanId) {
707
+ Logger.warn(`[ScreenLoading] Navigation span ${span.spanId} timed out - state never changed`);
708
+ ScreenLoadingManager.endSpan(span.spanId);
709
+ _activeNavigationSpanId = null;
710
+ }
711
+ }, STATE_CHANGE_TIMEOUT_MS);
712
+ }
713
+ }
714
+ };
715
+ /**
716
+ * Handles React Navigation's state event
717
+ * This fires AFTER the navigation state has changed (the screen is mounted)
718
+ */
719
+ const _onNavigationStateChange = () => {
720
+ if (!_navigationRef?.current) {
721
+ return;
722
+ }
723
+ const previousRouteName = _currentRoute;
724
+ const currentRoute = _navigationRef.current.getCurrentRoute();
725
+ const currentRouteName = currentRoute?.name || null;
726
+ // If no route or same route, ignore
727
+ if (!currentRouteName || previousRouteName === currentRouteName) {
728
+ // Still need to clean up the span if one was created
729
+ if (_activeNavigationSpanId) {
730
+ Logger.log('[ScreenLoading] Navigation resulted in same route, discarding span');
731
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId);
732
+ _activeNavigationSpanId = null;
733
+ _clearStateChangeTimeout();
734
+ }
735
+ return;
736
+ }
737
+ // Capture the span ID BEFORE clearing it so we can pass it to reportScreenChange
738
+ let spanIdForReport = _activeNavigationSpanId;
739
+ // Complete the active navigation span if one exists
740
+ if (_activeNavigationSpanId) {
741
+ // Now that we know the actual route name, check if it's excluded
742
+ if (ScreenLoadingManager.isRouteExcluded(currentRouteName)) {
743
+ Logger.log(`[ScreenLoading] Route "${currentRouteName}" is excluded, discarding span`);
744
+ ScreenLoadingManager.discardSpan(_activeNavigationSpanId);
745
+ spanIdForReport = null;
746
+ _activeNavigationSpanId = null;
747
+ _clearStateChangeTimeout();
748
+ }
749
+ else {
750
+ const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
751
+ if (span) {
752
+ // Update the span name from placeholder to actual screen name
753
+ span.screenName = currentRouteName;
754
+ // End the span - the native frame tracker will provide the actual render timestamp
755
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId)
756
+ .then(() => {
757
+ Logger.log(`[ScreenLoading] Completed span for navigation to ${currentRouteName}`);
758
+ })
759
+ .catch((error) => {
760
+ Logger.warn('[ScreenLoading] Failed to end navigation span:', error);
761
+ });
762
+ }
763
+ // Clear the active span and timeout
764
+ _activeNavigationSpanId = null;
765
+ _clearStateChangeTimeout();
766
+ }
767
+ }
768
+ // Update the current route for the rest of Luciq's tracking
769
+ _currentRoute = currentRouteName;
770
+ // Report to native
771
+ NativeLuciq.reportScreenChange(currentRouteName, spanIdForReport);
772
+ };
649
773
  export const onNavigationStateChange = (prevState, currentState, _action) => {
650
774
  const currentScreen = LuciqUtils.getActiveRouteName(currentState);
651
775
  const prevScreen = LuciqUtils.getActiveRouteName(prevState);
652
776
  if (prevScreen !== currentScreen) {
777
+ // Start Screen Loading measurement for v4
778
+ let screenLoadingSpanId = null;
779
+ if (ScreenLoadingManager.isFeatureEnabled()) {
780
+ const span = ScreenLoadingManager.createSpan(currentScreen || 'Unknown', false);
781
+ if (span) {
782
+ screenLoadingSpanId = span.spanId;
783
+ }
784
+ }
653
785
  reportCurrentViewForAndroid(currentScreen);
654
786
  if (_currentScreen != null && _currentScreen !== firstScreen) {
655
- NativeLuciq.reportScreenChange(_currentScreen);
787
+ NativeLuciq.reportScreenChange(_currentScreen, screenLoadingSpanId);
656
788
  _currentScreen = null;
657
789
  }
658
790
  _currentScreen = currentScreen;
659
791
  setTimeout(() => {
660
792
  if (currentScreen && _currentScreen === currentScreen) {
661
- NativeLuciq.reportScreenChange(currentScreen);
793
+ NativeLuciq.reportScreenChange(currentScreen, screenLoadingSpanId);
662
794
  _currentScreen = null;
663
795
  }
796
+ // End Screen Loading measurement for v4
797
+ if (screenLoadingSpanId) {
798
+ ScreenLoadingManager.endSpan(screenLoadingSpanId).catch((error) => {
799
+ Logger.warn('[ScreenLoading] Failed to end span:', error);
800
+ });
801
+ }
664
802
  }, 1000);
665
803
  }
666
804
  };
@@ -668,16 +806,25 @@ export const onStateChange = (state) => {
668
806
  if (!state) {
669
807
  return;
670
808
  }
809
+ // Delegate to the new state change handler for Screen Loading
810
+ // This handles reportScreenChange when setNavigationListener was called
811
+ _onNavigationStateChange();
812
+ // When setNavigationListener is used, _onNavigationStateChange already handles
813
+ // reportScreenChange properly - skip legacy logic to avoid duplicate calls
814
+ if (_navigationRef?.current) {
815
+ return;
816
+ }
817
+ // Fallback: Legacy screen tracking for users who only use onStateChange without setNavigationListener
671
818
  const currentScreen = LuciqUtils.getFullRoute(state);
672
819
  reportCurrentViewForAndroid(currentScreen);
673
820
  if (_currentScreen !== null && _currentScreen !== firstScreen) {
674
- NativeLuciq.reportScreenChange(_currentScreen);
821
+ NativeLuciq.reportScreenChange(_currentScreen, null);
675
822
  _currentScreen = null;
676
823
  }
677
824
  _currentScreen = currentScreen;
678
825
  setTimeout(() => {
679
826
  if (_currentScreen === currentScreen) {
680
- NativeLuciq.reportScreenChange(currentScreen);
827
+ NativeLuciq.reportScreenChange(currentScreen, null);
681
828
  _currentScreen = null;
682
829
  }
683
830
  }, 1000);
@@ -688,12 +835,23 @@ export const onStateChange = (state) => {
688
835
  *
689
836
  */
690
837
  export const setNavigationListener = (navigationRef) => {
691
- return navigationRef.addListener('state', () => {
692
- onStateChange(navigationRef.getRootState());
693
- });
838
+ // Store the navigationRef for Screen Loading tracking
839
+ _navigationRef = navigationRef;
840
+ if (!navigationRef?.current) {
841
+ Logger.warn('[Luciq] Navigation ref not available, cannot set listeners');
842
+ return;
843
+ }
844
+ // Register the __unsafe_action__ listener for span creation
845
+ // This listener fires on navigation dispatch (start of navigation)
846
+ navigationRef.current.addListener('__unsafe_action__', _onNavigationAction);
847
+ // NOTE: We do NOT register a 'state' listener here because the user is expected
848
+ // to pass Luciq.onStateChange to NavigationContainer's onStateChange prop.
849
+ // Registering both would cause duplicate reportScreenChange calls.
850
+ Logger.log('[Luciq] Registered Screen Loading listener (__unsafe_action__)');
851
+ // return stateListener;
694
852
  };
695
853
  export const reportScreenChange = (screenName) => {
696
- NativeLuciq.reportScreenChange(screenName);
854
+ NativeLuciq.reportScreenChange(screenName, null);
697
855
  };
698
856
  /**
699
857
  * Add feature flags to the next report.
@@ -749,7 +907,7 @@ export const componentDidAppearListener = (event) => {
749
907
  return;
750
908
  }
751
909
  if (_lastScreen !== event.componentName) {
752
- NativeLuciq.reportScreenChange(event.componentName);
910
+ NativeLuciq.reportScreenChange(event.componentName, null);
753
911
  _lastScreen = event.componentName;
754
912
  }
755
913
  };
@@ -0,0 +1,99 @@
1
+ export interface ScreenLoadingSpan {
2
+ spanId: string;
3
+ screenName: string;
4
+ startTimestamp: number;
5
+ endTimestamp?: number;
6
+ ttid?: number;
7
+ status: 'pending' | 'measuring' | 'completed' | 'error';
8
+ isManual: boolean;
9
+ attributes: Map<string, number>;
10
+ }
11
+ /**
12
+ * Automatic Screen Loading Measurement
13
+ *
14
+ * Start point: `__unsafe_action__` navigation event (below).
15
+ * - Fires when a navigation action is dispatched, before the target screen mounts.
16
+ * - `ScreenLoadingManager.createSpan()` records `nowMicros()` as the span start.
17
+ *
18
+ * End point: `_onNavigationStateChange()` (called from `onStateChange`).
19
+ * - Fires after navigation state has settled and the new screen is mounted.
20
+ * - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp from
21
+ * CADisplayLink (iOS) / Choreographer (Android) to mark actual render completion.
22
+ * - The TTID is: native frame timestamp − span start.
23
+ *
24
+ *
25
+ * Manual Screen Loading Measurement
26
+ *
27
+ * Start point: Component instantiation (lazy init block before first render).
28
+ * - `nowMicros()` is captured as `constructorTimestampRef` and passed to
29
+ * `ScreenLoadingManager.createSpan()` as the span's start timestamp.
30
+ *
31
+ * End point: `useLayoutEffect` (fires synchronously after React commits DOM
32
+ * mutations, before the browser paints).
33
+ * - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp
34
+ * from CADisplayLink (iOS) / Choreographer (Android) to mark the actual
35
+ * render completion. The TTID is: native frame timestamp − span start.
36
+ *
37
+ * Both approaches share the same `endSpan()` path so TTID values are comparable.
38
+ */
39
+ declare class ScreenLoadingManagerClass {
40
+ private activeSpans;
41
+ private isInitialized;
42
+ private isEnabled;
43
+ private isEndScreenLoadingEnabled;
44
+ private isFrameTrackingInitialized;
45
+ private activeSpanId;
46
+ private maxConcurrentSpans;
47
+ private excludedRoutes;
48
+ initialize(): Promise<void>;
49
+ refreshFlags(): Promise<void>;
50
+ /**
51
+ * Exclude specific routes from automatic screen loading measurement
52
+ * @param routes Array of route names to exclude
53
+ */
54
+ excludeRoutes(routes: string[]): void;
55
+ /**
56
+ * Include previously excluded routes back into screen loading measurement
57
+ * @param routes Array of route names to include (or empty to clear all exclusions)
58
+ */
59
+ includeRoutes(routes?: string[]): void;
60
+ /**
61
+ * Check if a route is excluded from measurement
62
+ */
63
+ isRouteExcluded(routeName: string): boolean;
64
+ /**
65
+ * Create a new screen loading span
66
+ * @param screenName Name of the screen
67
+ * @param isManual Whether the span is manual (not automatically created)
68
+ * @param startTimestampParam Optional start timestamp in microseconds (defaults to nowMicros())
69
+ * @returns The created span or null if the feature is not enabled
70
+ */
71
+ createSpan(screenName: string, isManual?: boolean, startTimestampParam?: number): ScreenLoadingSpan | null;
72
+ /**
73
+ * End a screen loading span
74
+ * @param spanId The ID of the span to end
75
+ */
76
+ endSpan(spanId: string): Promise<void>;
77
+ /**
78
+ * Log a screen loading span
79
+ * @param span The span to log
80
+ */
81
+ private logScreenLoading;
82
+ /**
83
+ * End a screen loading span using the current timestamp and active span ID
84
+ */
85
+ endScreenLoading(): void;
86
+ /**
87
+ * Discard a span without logging or syncing it to native.
88
+ * Used when a span should be silently dropped (e.g., excluded route resolved after creation).
89
+ */
90
+ discardSpan(spanId: string): void;
91
+ getActiveSpan(spanId: string): ScreenLoadingSpan | undefined;
92
+ getAllActiveSpans(): ScreenLoadingSpan[];
93
+ addSpanAttribute(spanId: string, key: string, value: number): void;
94
+ private cleanupOldestSpans;
95
+ isFeatureEnabled(): boolean;
96
+ isEndScreenLoadingFeatureEnabled(): boolean;
97
+ }
98
+ export declare const ScreenLoadingManager: ScreenLoadingManagerClass;
99
+ export {};