@scalebun/react-native 2.0.0 → 2.0.2

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 (70) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +18 -3
  4. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -1
  5. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  6. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +2 -0
  7. package/dist/scalebun.full.js +840 -114
  8. package/dist/scalebun.slim.js +838 -112
  9. package/ios/ReplaySdk.swift +65 -2
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  11. package/lib/commonjs/core/config/schema.js +16 -0
  12. package/lib/commonjs/core/constants/version.js +1 -1
  13. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  14. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  15. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  16. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  17. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  18. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  19. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  20. package/lib/commonjs/features/journey/uiState.js +81 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +69 -22
  23. package/lib/commonjs/features/session/SessionManager.js +63 -0
  24. package/lib/commonjs/public/ScaleBunFacade.js +18 -16
  25. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  26. package/lib/module/core/config/schema.js +16 -0
  27. package/lib/module/core/constants/version.js +1 -1
  28. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  29. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  30. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  31. package/lib/module/features/journey/interactionProtocol.js +128 -8
  32. package/lib/module/features/journey/scrollContext.js +135 -0
  33. package/lib/module/features/journey/targetGeometry.js +155 -0
  34. package/lib/module/features/journey/touchTarget.js +151 -24
  35. package/lib/module/features/journey/uiState.js +78 -1
  36. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  37. package/lib/module/features/ota/OtaOrchestrator.js +69 -22
  38. package/lib/module/features/session/SessionManager.js +63 -0
  39. package/lib/module/public/ScaleBunFacade.js +18 -16
  40. package/lib/typescript/core/config/schema.d.ts +2 -0
  41. package/lib/typescript/core/constants/version.d.ts +1 -1
  42. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  43. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  44. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  45. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  46. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  47. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  48. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  49. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +7 -0
  50. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  51. package/lib/typescript/public/types.d.ts +10 -0
  52. package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
  53. package/package.json +2 -2
  54. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  55. package/src/core/config/schema.ts +18 -0
  56. package/src/core/constants/version.ts +1 -1
  57. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  58. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  59. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  60. package/src/features/journey/interactionProtocol.ts +188 -10
  61. package/src/features/journey/scrollContext.ts +137 -0
  62. package/src/features/journey/targetGeometry.ts +176 -0
  63. package/src/features/journey/touchTarget.ts +237 -26
  64. package/src/features/journey/uiState.ts +80 -1
  65. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  66. package/src/features/ota/OtaOrchestrator.ts +76 -22
  67. package/src/features/session/SessionManager.ts +113 -0
  68. package/src/public/ScaleBunFacade.ts +18 -16
  69. package/src/public/types.ts +10 -0
  70. package/src/specs/NativeScaleBunOta.ts +3 -0
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import React
2
3
  import UIKit
3
4
 
4
5
  /// Main ReplaySdk native module (RCTEventEmitter subclass).
@@ -32,12 +33,57 @@ class ReplaySdk: RCTEventEmitter {
32
33
  }
33
34
 
34
35
  /// Attach native touch tracking to the key window.
35
- /// Called from initialize() after a short delay to ensure the window is ready.
36
+ ///
37
+ /// RETRIED, AND RE-ATTACHED ON FOREGROUND — because the previous one-shot version is the most
38
+ /// likely explanation for iOS capturing coordinates on 0.4% of interactions where Android
39
+ /// manages 15.3%.
40
+ ///
41
+ /// It ran exactly once, 500 ms after `initialize()`. If `getKeyWindow()` returned nil at that
42
+ /// instant it logged a warning and never tried again, so native touch tracking was off for the
43
+ /// WHOLE app session. A cold start behind a heavy JS bundle, a slow device, or a scene-based
44
+ /// app whose key window is not the one that existed at +500 ms — any of those and no touch is
45
+ /// ever tracked. Losing that race once lost it permanently.
46
+ ///
47
+ /// Android never had the problem because it attaches from a lifecycle hook
48
+ /// (`ReplaySdkModule.onHostResume`), so every foreground corrects an attach that was too early.
49
+ /// This mirrors that: bounded retries for the cold-start race, plus a foreground observer that
50
+ /// attaches if a previous attempt never succeeded. `attach` is idempotent —
51
+ /// `guard !isActive else { return }` — so a redundant call costs nothing and cannot install a
52
+ /// second gesture recognizer.
53
+ ///
54
+ /// WHAT THIS DOES NOT FIX, stated because that same guard is why: a key window that CHANGES
55
+ /// after a successful attach. `attach` returns early on `isActive` without comparing the
56
+ /// window, so the tracker stays bound to the original one and the foreground observer cannot
57
+ /// help. That needs a detach-and-rebind inside `InteractionTracker`, which is a larger change
58
+ /// than the race this addresses. The cold-start race is the dominant cause — it costs the whole
59
+ /// session every time it is lost — and it is fixed here; the window-change case remains open.
36
60
  private func activateNativeTracking() {
61
+ scheduleAttach(attemptsRemaining: 10)
62
+ NotificationCenter.default.addObserver(
63
+ self,
64
+ selector: #selector(reattachNativeTracking),
65
+ name: UIApplication.didBecomeActiveNotification,
66
+ object: nil
67
+ )
68
+ }
69
+
70
+ /// Try to attach, and try again shortly if there is no key window yet.
71
+ ///
72
+ /// Ten attempts at 500 ms covers about five seconds of startup, which is far longer than a
73
+ /// window takes to appear and short enough that a genuinely window-less process stops asking.
74
+ /// The last failure is logged at warn so a host that never gets tracking is told, rather than
75
+ /// discovering it as an empty heatmap.
76
+ private func scheduleAttach(attemptsRemaining: Int) {
37
77
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
38
78
  guard let self = self else { return }
39
79
  guard let window = Self.getKeyWindow() else {
40
- ReplayLogger.warn("Native tracking: no key window available")
80
+ if attemptsRemaining > 1 {
81
+ self.scheduleAttach(attemptsRemaining: attemptsRemaining - 1)
82
+ } else {
83
+ ReplayLogger.warn(
84
+ "Native tracking: no key window after repeated attempts — touch capture is off"
85
+ )
86
+ }
41
87
  return
42
88
  }
43
89
  self.interactionTracker.attach(to: window, sendEvent: { [weak self] name, payload in
@@ -46,6 +92,23 @@ class ReplaySdk: RCTEventEmitter {
46
92
  }
47
93
  }
48
94
 
95
+ /// Re-attach when the app becomes active, mirroring Android's `onHostResume`.
96
+ @objc private func reattachNativeTracking() {
97
+ guard let window = Self.getKeyWindow() else { return }
98
+ interactionTracker.attach(to: window, sendEvent: { [weak self] name, payload in
99
+ self?.sendEvent(withName: name, body: payload)
100
+ })
101
+ }
102
+
103
+ /// Matches what the other two observer-registering modules here do
104
+ /// (`SessionManager`, `ScaleBunProfilerModule`). A native module usually outlives the app's
105
+ /// interesting lifetime and selector observers have been zeroing-weak since iOS 9, so this is
106
+ /// hygiene rather than a crash fix — but being the one file that registers and never removes is
107
+ /// how a convention quietly stops being one.
108
+ deinit {
109
+ NotificationCenter.default.removeObserver(self)
110
+ }
111
+
49
112
  private static func getKeyWindow() -> UIWindow? {
50
113
  return WindowResolver.keyWindow()
51
114
  }
@@ -80,6 +80,15 @@ class SDKBootstrapper {
80
80
  // apiBaseUrl is LOCKED: whatever an integrator passed (or the schema default) is
81
81
  // discarded here so the SDK can never be repointed away from the ScaleBun cloud.
82
82
  // Change DEFAULT_API_BASE_URL at build time to target a different backend.
83
+ // Say so when we discard it. The value was accepted by the schema, silently
84
+ // overwritten here, and the SDK then talked to a different server than the
85
+ // integrator configured — with no log, no warning, and no error. Someone
86
+ // pointing the SDK at a staging or local backend would watch requests arrive
87
+ // at production and have nothing to go on.
88
+ const requestedApiBaseUrl = rawConfig?.apiBaseUrl;
89
+ if (typeof requestedApiBaseUrl === 'string' && requestedApiBaseUrl && requestedApiBaseUrl !== _endpoints.DEFAULT_API_BASE_URL) {
90
+ _internalLogger.logger.warn(`[ScaleBun] Ignoring apiBaseUrl '${requestedApiBaseUrl}' — the endpoint is ` + `locked at build time and this build targets ${_endpoints.DEFAULT_API_BASE_URL}. To ` + `target another backend, change DEFAULT_API_BASE_URL in the SDK source and ` + `rebuild.`);
91
+ }
83
92
  this.config.apiBaseUrl = _endpoints.DEFAULT_API_BASE_URL;
84
93
  // Explicit logLevel wins; otherwise debug:true implies verbose 'debug'.
85
94
  if (this.config.logLevel) {
@@ -319,6 +328,27 @@ class SDKBootstrapper {
319
328
  await this.featureRegistry.initializeAll(featureContext);
320
329
 
321
330
  // ── Session Replay Initialization (Core) ───────────────
331
+ /**
332
+ * RN-3 zero-config scroll instrumentation, opt-in.
333
+ *
334
+ * Placed before replay init because it patches a module export and must be in place before the
335
+ * host renders its first scroll view. Reports rather than assumes: the equivalent React
336
+ * Navigation hook once claimed success it had not verified and left a whole pipeline silently
337
+ * empty, so a failure here is said out loud with a reason an integrator can act on.
338
+ */
339
+ if (this.config.autoInstrumentScrollViews === true) {
340
+ try {
341
+ const {
342
+ autoInstrumentScrollViews
343
+ } = require('../features/journey/autoInstrumentScroll');
344
+ const outcome = autoInstrumentScrollViews();
345
+ if (!outcome.installed) {
346
+ _internalLogger.logger.warn('[Bootstrap] autoInstrumentScrollViews was requested but could not be ' + 'installed: ' + outcome.reason + ' — taps will carry no content depth.');
347
+ } else {
348
+ __DEV__ && _internalLogger.logger.debug('[Bootstrap] ScrollView auto-instrumentation installed');
349
+ }
350
+ } catch {/* no-throw */}
351
+ }
322
352
  if (features.replay !== false) {
323
353
  try {
324
354
  __DEV__ && _internalLogger.logger.debug('[Bootstrap] Initializing unified SessionManager');
@@ -275,6 +275,22 @@ const SHAPE = {
275
275
  }
276
276
  },
277
277
  captureInteractionHeatmap: bool(true),
278
+ /**
279
+ * RN-3 zero-config: patch React Native's ScrollView export so EVERY scroll view reports its
280
+ * offset, not just ones wrapped in ScaleBunScrollView.
281
+ *
282
+ * DEFAULT FALSE, unlike captureInteractionHeatmap above, and the difference is deliberate.
283
+ * That flag changes what the SDK records about the host; this one changes what the HOST
284
+ * RENDERS — every scroll view in the app, including ones inside third-party libraries the app
285
+ * did not write, starts routing through an SDK component. The failure mode is not a wrong
286
+ * number in a dashboard, it is the host's UI behaving differently because an analytics SDK
287
+ * replaced a primitive. That is a decision an integrator makes deliberately, not one they
288
+ * discover afterwards.
289
+ *
290
+ * With it off, a plain <ScrollView> yields no content depth and the tap says so
291
+ * (scroll_source: 'none') rather than defaulting its offset to 0.
292
+ */
293
+ autoInstrumentScrollViews: bool(false),
278
294
  // Opt-in: intercept console.* and ship the lines to the Diagnose → Logs lane in SaaS
279
295
  // mode. OFF by default — console output can carry PII/secrets and adds ingest volume,
280
296
  // so capturing it is the host's explicit choice, not a silent default. When true and a
@@ -14,5 +14,5 @@ exports.SDK_VERSION = void 0;
14
14
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
15
15
  * version was introduced to solve.
16
16
  */
17
- const SDK_VERSION = exports.SDK_VERSION = '2.0.0';
17
+ const SDK_VERSION = exports.SDK_VERSION = '2.0.2';
18
18
  //# sourceMappingURL=version.js.map
@@ -9,6 +9,8 @@ exports.useScaleBunScreen = useScaleBunScreen;
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _reactNative = require("react-native");
11
11
  var _touchTarget = require("./touchTarget");
12
+ var _targetGeometry = require("./targetGeometry");
13
+ var _scrollContext = require("./scrollContext");
12
14
  var _device = require("../../core/context/device");
13
15
  var _interactionProtocol = require("./interactionProtocol");
14
16
  var _jsxRuntime = require("react/jsx-runtime");
@@ -50,6 +52,15 @@ function getBootstrap() {
50
52
 
51
53
  const JourneyContext = /*#__PURE__*/(0, _react.createContext)(null);
52
54
 
55
+ /**
56
+ * Floor on how often a UI-state change may request a screenshot.
57
+ *
58
+ * One frame per second is far finer than any state a person perceives changing, and far coarser
59
+ * than a render loop — which is the case that matters, because a host may legitimately declare
60
+ * state from inside render and an oscillating dimension would otherwise capture continuously.
61
+ */
62
+ const UI_STATE_CAPTURE_MIN_INTERVAL_MS = 1000;
63
+
53
64
  // ─── ScaleBunDebugRoot ───────────────────────────────────────────────────────────
54
65
 
55
66
  function ScaleBunDebugRoot({
@@ -259,6 +270,54 @@ function ScaleBunDebugRoot({
259
270
  };
260
271
  }, [navigationRef]);
261
272
 
273
+ /**
274
+ * RN-11 — CAPTURE A FRAME WHEN THE UI CHANGES SHAPE.
275
+ *
276
+ * A declared state used to have no picture of its own. Nothing captured on `setUiState`, so a
277
+ * state's visual existed only if a screenshot happened to be taken for another reason — a
278
+ * navigation, a timer, or the interaction capture that fires AFTER the touch and therefore
279
+ * photographs the outcome. A drawer could be recorded as open with no frame showing it open,
280
+ * or with the frame of the screen that replaced it.
281
+ *
282
+ * The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER; none of them means
283
+ * "the UI changed shape". This routes through the MANUAL path, which is the only one reachable
284
+ * from JS. A dedicated native reason would let the backend tell a state capture from a
285
+ * developer-requested one, and that is a native change on both platforms — worth doing, not
286
+ * doable from here.
287
+ *
288
+ * RATE LIMITED, and the limit is not politeness. A screenshot is expensive, and while
289
+ * `uiState` already collapses redundant declarations, a genuinely oscillating dimension (a
290
+ * spinner toggling, a live-updating badge) would otherwise capture continuously. One frame per
291
+ * second is far finer than any state a person can perceive changing and far coarser than a
292
+ * render loop.
293
+ *
294
+ * Requests only; never awaits and never throws. If the session is not recording,
295
+ * `captureManualFrame` declines and the state simply has no frame — which is the honest
296
+ * outcome and is exactly what the analytics-only lane already reports elsewhere.
297
+ */
298
+ (0, _react.useEffect)(() => {
299
+ let lastCaptureTs = 0;
300
+ const {
301
+ onUiStateChange
302
+ } = require('./uiState');
303
+ const unsubscribe = onUiStateChange(() => {
304
+ const now = Date.now();
305
+ if (now - lastCaptureTs < UI_STATE_CAPTURE_MIN_INTERVAL_MS) return;
306
+ lastCaptureTs = now;
307
+ try {
308
+ const {
309
+ SessionManager
310
+ } = require('../session/SessionManager');
311
+ const sm = SessionManager.getExistingInstance();
312
+ if (sm && typeof sm.captureManualFrame === 'function') {
313
+ /* Fire and forget: the tap that follows must not wait on a screenshot. */
314
+ void sm.captureManualFrame();
315
+ }
316
+ } catch {/* no-throw */}
317
+ });
318
+ return unsubscribe;
319
+ }, []);
320
+
262
321
  // ─── Native Interaction Listener ────────────────────────────────────
263
322
  // Receives high-confidence ScaleBunNativeInteraction events from the native tracker
264
323
  // and forwards them to SessionManager as USER_ACTION events.
@@ -279,7 +338,27 @@ function ScaleBunDebugRoot({
279
338
  lastNativeTouchTsRef.current = Date.now();
280
339
  nativeTrackingConfirmedRef.current = true;
281
340
  const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
282
- const start = (0, _interactionProtocol.nearestInteractionStart)(interactionStartsRef.current, nativeOccurredAt);
341
+ /**
342
+ * RN-6 — correlate, then CONSUME. See interactionProtocol.ts for why
343
+ * the join is on time at all, and why a refusal is the important part.
344
+ *
345
+ * Pruning first is part of the correctness, not tidying: a start older
346
+ * than the tolerance can never be paired, but while it sits in the list
347
+ * it is still counted by the ambiguity test, so a stale entry can turn a
348
+ * clear pairing into a refusal — and the list only grows.
349
+ */
350
+ (0, _interactionProtocol.pruneInteractionStarts)(interactionStartsRef.current, nativeOccurredAt);
351
+ const correlation = (0, _interactionProtocol.correlateInteraction)(interactionStartsRef.current, nativeOccurredAt);
352
+ const start = correlation.start;
353
+ /**
354
+ * ONE-TO-ONE. Without removing the matched start, a second native event
355
+ * arriving within the window claims the SAME js evidence, so one tap's
356
+ * target is stamped onto another tap that never had one — a duplicate
357
+ * identity that reads as two genuine interactions on the same control.
358
+ */
359
+ if (correlation.index >= 0) {
360
+ interactionStartsRef.current.splice(correlation.index, 1);
361
+ }
283
362
 
284
363
  // Cancel only this physical touch's fallback. A single timer used to cancel a
285
364
  // previous rapid tap and silently lose it on devices without native tracking.
@@ -311,11 +390,62 @@ function ScaleBunDebugRoot({
311
390
  interactionId: event.interactionId || start?.interactionId || (0, _interactionProtocol.generateInteractionId)(),
312
391
  interactionProtocol: event.interactionProtocol || _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
313
392
  occurredAt: nativeOccurredAt,
393
+ /**
394
+ * RN-6 — THESE FIVE FIELDS ARE THE ONLY ONES THE JOIN CARRIES, AND
395
+ * THEREFORE THE ONLY ONES A BAD JOIN CAN FALSIFY.
396
+ *
397
+ * Coordinates below come from native and are right regardless. So a
398
+ * mispairing does not produce visibly broken data: it produces heat
399
+ * in the correct position attributed to the wrong control, in the
400
+ * wrong UI state, on the wrong screen. Every field is individually
401
+ * well-formed and nothing downstream can tell.
402
+ *
403
+ * When the correlation is not 'exact' they are left UNDEFINED rather
404
+ * than filled from a best guess. An interaction with no target is
405
+ * honest and visibly incomplete — the accounting lane can count it as
406
+ * unplaceable and say why. An interaction with the WRONG target is
407
+ * indistinguishable from a correct one and silently corrupts every
408
+ * aggregate it enters. `null` is not `0`, and a guess is not a
409
+ * measurement.
410
+ */
314
411
  ui: start?.ui,
315
412
  stateStatus: start?.stateStatus ?? 'not_captured',
316
413
  target: start?.target,
317
414
  targetId: start?.targetId,
415
+ targetSource: start?.targetSource,
318
416
  screenName: start?.screenName,
417
+ screenSource: start?.screenSource ?? 'none',
418
+ targetRect: start?.targetRect,
419
+ /**
420
+ * RN-15 — WHERE IN THE CONTROL, computed where both halves exist: the
421
+ * point comes from the native event and the rect was measured at
422
+ * finger-down, in the same capture base. Pairing them here rather than
423
+ * downstream stops anything later reaching for a different rect.
424
+ */
425
+ ...(() => {
426
+ const lp = (0, _targetGeometry.localPoint)(start?.targetRect ?? null, event.normalizedX, event.normalizedY);
427
+ return lp ? {
428
+ localU: lp.u,
429
+ localV: lp.v
430
+ } : {};
431
+ })(),
432
+ scrollX: start?.scrollX,
433
+ scrollY: start?.scrollY,
434
+ scrollSource: start?.scrollSource ?? 'none',
435
+ itemKey: start?.itemKey,
436
+ itemIndex: start?.itemIndex,
437
+ /**
438
+ * The join's own verdict, shipped with the row so it is auditable
439
+ * rather than a claim made in a comment. 'ambiguous' and 'none' are
440
+ * deliberately distinguished: 'none' means the JS handler never fired
441
+ * (a real capture gap on this platform), 'ambiguous' means it fired
442
+ * more than once too close together to attribute. Those have
443
+ * different fixes, and collapsing them would hide which one is
444
+ * actually happening in production.
445
+ */
446
+ correlation: correlation.outcome,
447
+ correlationDeltaMs: correlation.deltaMs,
448
+ correlationRunnerUpDeltaMs: correlation.runnerUpDeltaMs,
319
449
  emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
320
450
  x: event.rawX,
321
451
  y: event.rawY,
@@ -398,6 +528,16 @@ function ScaleBunDebugRoot({
398
528
  const target = (0, _touchTarget.resolveTouchTarget)(e);
399
529
  let ui;
400
530
  let screenName;
531
+ /**
532
+ * RN-8 — WHERE the screen name came from, carried with the interaction.
533
+ *
534
+ * Production shows 13.8% of Android interactions with a screen and 0.5% on iOS, and
535
+ * those figures cannot distinguish three unrelated causes: nothing was instrumented,
536
+ * a manual beacon was instrumented but never reached interactions, or auto-detection
537
+ * ran and failed. Each has a different owner. Recording the source turns one
538
+ * unactionable percentage into three answerable ones.
539
+ */
540
+ let screenSource = 'none';
401
541
  try {
402
542
  const {
403
543
  uiStateSignature
@@ -408,7 +548,12 @@ function ScaleBunDebugRoot({
408
548
  const {
409
549
  AutoScreenDetector
410
550
  } = require('../navigation/AutoScreenDetector');
411
- screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
551
+ const detector = AutoScreenDetector.getInstance();
552
+ screenName = detector.getCurrentScreen() || undefined;
553
+ /* Read from the SAME call site as the name, so the two can never describe
554
+ different moments — the detector is polled and its answer can change between
555
+ two reads a few milliseconds apart. */
556
+ screenSource = screenName ? detector.getCurrentScreenSource() : 'none';
412
557
  } catch {/* no-throw */}
413
558
  const start = {
414
559
  interactionId: (0, _interactionProtocol.generateInteractionId)(),
@@ -417,9 +562,93 @@ function ScaleBunDebugRoot({
417
562
  y: touch.pageY,
418
563
  locationX: touch.locationX,
419
564
  locationY: touch.locationY,
565
+ /**
566
+ * RN-1 + RN-2 — the identity that is persisted, and it is now code-derived.
567
+ *
568
+ * `describeTouchTarget` returns `testID` -> structural path -> component name,
569
+ * and never a caption or an accessibilityLabel. Before RN-2 it preferred free
570
+ * text, so this line was writing user data ("Pay EGP 1,442", an order row
571
+ * labelled with an email address, a contact's name) into the analytics store —
572
+ * and, because `target` is an aggregation key, splitting one button into one
573
+ * bucket per person at the same time.
574
+ *
575
+ * `targetId` used to be `target?.testID` alone, which is `undefined` in the
576
+ * overwhelming majority of apps — no testIDs authored — so the field the
577
+ * backend keys on was empty almost always. It now falls back to the structural
578
+ * path, with `targetSource` recording WHICH it got.
579
+ */
420
580
  target: (0, _touchTarget.describeTouchTarget)(target),
421
- targetId: target?.testID,
581
+ targetId: (0, _touchTarget.targetIdentity)(target).id,
582
+ /**
583
+ * WHICH KIND of identity this is, because they are not equally trustworthy: a
584
+ * `testID` is a promise from the app's authors that this control is this
585
+ * control; a `path` is an inference that breaks when the tree is refactored.
586
+ * Without this the accounting lane cannot distinguish "the control had no
587
+ * identity" from "the identity is a structural guess", and both would render
588
+ * as an equally confident target.
589
+ */
590
+ targetSource: (0, _touchTarget.targetIdentity)(target).source,
591
+ /* RN-4 — row identity from the app's own keyExtractor; see touchTarget.ts. */
592
+ itemKey: target?.itemKey,
593
+ itemIndex: target?.itemIndex,
594
+ /**
595
+ * RN-13 — the control's rectangle, so `heat ∈ target` is decidable.
596
+ *
597
+ * Read SYNCHRONOUSLY here, in the same tick as the touch, and normalized against
598
+ * the SAME measured root the tap's own coordinates use. Both halves matter:
599
+ *
600
+ * - A callback measurement (`measureInWindow`) would resolve after this handler
601
+ * returns, so the rect attached to a tap could be the previous tap's, with
602
+ * nothing afterwards able to tell. `targetGeometry` refuses to guess: it reads
603
+ * now or reports null.
604
+ * - Normalizing against a window size instead of the root view is off by the
605
+ * status bar on most devices, which would read as "the heat is just outside
606
+ * the button" everywhere rather than as a measurement bug.
607
+ *
608
+ * `measureRoot()` is called first because the cached root rect is populated
609
+ * asynchronously at mount; without it the first taps of a session normalize
610
+ * against nothing and get a null rect for a reason that has nothing to do with
611
+ * the control.
612
+ */
613
+ /**
614
+ * RN-3 — WHERE IN THE CONTENT, not just where on the glass.
615
+ *
616
+ * Two taps at the same screen coordinate one viewport apart are two different
617
+ * controls. Without an offset they merge into one hotspot, and the heatmap shows
618
+ * heavy engagement at a position where, in content terms, nothing in particular
619
+ * is. The offset is also a CACHE dimension — two scroll states of one route must
620
+ * not share a backdrop — so its absence corrupts the picture as well as the count.
621
+ *
622
+ * `ancestors` is innermost-first, so this takes the NEAREST scroller that has a
623
+ * recorded position: a tap inside a horizontal carousel belongs to the carousel's
624
+ * content, not to how far the page behind it had scrolled.
625
+ *
626
+ * Null when no ancestor has one — never zero. Zero is a real scroll position (the
627
+ * top of a list), and reporting it for "unknown" would place every unmeasured tap
628
+ * at the top of its content, indistinguishable from a genuine tap there.
629
+ */
630
+ ...(() => {
631
+ const hit = (0, _scrollContext.nearestRecordedOffset)((target?.ancestors ?? []).map(n => (0, _scrollContext.nativeTagOf)(n)));
632
+ return hit ? {
633
+ scrollX: hit.x,
634
+ scrollY: hit.y,
635
+ scrollSource: 'wrapper'
636
+ } : {
637
+ scrollSource: 'none'
638
+ };
639
+ })(),
640
+ targetRect: (() => {
641
+ measureRoot();
642
+ const root = rootRectRef.current;
643
+ /* null -> undefined: the context field is optional, and an explicit null would
644
+ serialise as a JSON null claiming "measured, and it was nothing". */
645
+ return (0, _targetGeometry.normalizeRect)((0, _targetGeometry.readRectSync)(target?.node), root ? {
646
+ w: root.w,
647
+ h: root.h
648
+ } : null) ?? undefined;
649
+ })(),
422
650
  screenName,
651
+ screenSource,
423
652
  ui,
424
653
  stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
425
654
  emitAutomaticAnalytics: captureAutomaticInteractions
@@ -464,7 +693,21 @@ function ScaleBunDebugRoot({
464
693
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
465
694
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
466
695
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
467
- const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
696
+ /**
697
+ * Resolved ONCE, so `target` and `targetId` cannot disagree about the same tap.
698
+ *
699
+ * The previous shape called `resolveTouchTarget(e)` inline for `target` while
700
+ * `targetId` came only from `start` — so on the fallback path (no JS start, or a
701
+ * refused correlation) the tap carried a description with NO id, and the two fields
702
+ * were computed from different sources. Anything joining on `targetId` therefore
703
+ * saw nothing for a tap that visibly had a target.
704
+ */
705
+ const freshTarget = start ? null : (0, _touchTarget.resolveTouchTarget)(e);
706
+ const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)(freshTarget);
707
+ const identity = start ? {
708
+ id: start.targetId,
709
+ source: start.targetSource
710
+ } : (0, _touchTarget.targetIdentity)(freshTarget);
468
711
  const gestureDetails = {
469
712
  target: tapped,
470
713
  interactionId: start?.interactionId ?? (0, _interactionProtocol.generateInteractionId)(),
@@ -472,8 +715,16 @@ function ScaleBunDebugRoot({
472
715
  occurredAt: start?.occurredAt ?? Date.now(),
473
716
  ui: start?.ui,
474
717
  stateStatus: start?.stateStatus ?? 'not_captured',
475
- targetId: start?.targetId,
718
+ targetId: identity.id,
719
+ targetSource: identity.source,
476
720
  screenName: start?.screenName,
721
+ screenSource: start?.screenSource ?? 'none',
722
+ targetRect: start?.targetRect,
723
+ scrollX: start?.scrollX,
724
+ scrollY: start?.scrollY,
725
+ scrollSource: start?.scrollSource ?? 'none',
726
+ itemKey: start?.itemKey,
727
+ itemIndex: start?.itemIndex,
477
728
  emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
478
729
  x: start?.x ?? touch.pageX,
479
730
  y: start?.y ?? touch.pageY,
@@ -496,6 +747,19 @@ function ScaleBunDebugRoot({
496
747
  const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
497
748
  gestureDetails.normalizedX = nx;
498
749
  gestureDetails.normalizedY = ny;
750
+ /**
751
+ * RN-15 on the JS-fallback lane.
752
+ *
753
+ * Computed here rather than in the literal above because that is where nx/ny first
754
+ * exist. The rect is the SAME one the native lane uses, so one physical tap yields
755
+ * the same local position whichever lane captured it — two lanes disagreeing about
756
+ * where in a control a tap landed would be worse than neither reporting it.
757
+ */
758
+ const lp = (0, _targetGeometry.localPoint)(start?.targetRect ?? null, nx, ny);
759
+ if (lp) {
760
+ gestureDetails.localU = lp.u;
761
+ gestureDetails.localV = lp.v;
762
+ }
499
763
  gestureDetails.normalizedPrecomputed = true;
500
764
  gestureDetails.viewportWidth = rootRect.w;
501
765
  gestureDetails.viewportHeight = rootRect.h;
@@ -679,6 +943,30 @@ function useScaleBunScreen(name) {
679
943
  }
680
944
  } catch {/* no-throw */}
681
945
 
946
+ /**
947
+ * RN-8 — TELL THE DETECTOR TOO, because interactions read their screen from IT.
948
+ *
949
+ * This hook fed SessionManager, PerformanceFeature, ProfilerFeature and JourneyManager
950
+ * — four consumers — and never `AutoScreenDetector`. But `handleTouchStart` resolves an
951
+ * interaction's `screenName` from `AutoScreenDetector.getCurrentScreen()`, so a
952
+ * manually-instrumented app produced perfectly-named SCREEN_VIEW events while every TAP
953
+ * carried no screen at all. `setManualScreen` existed and was called only by tests.
954
+ *
955
+ * That is the whole story for apps the auto-hook cannot help: expo-router, a custom
956
+ * navigator, anything not `@react-navigation/native`. The documented manual escape hatch
957
+ * did not reach the one field that partitions every heatmap.
958
+ *
959
+ * Requires the last-writer-wins fix in `_currentScreen()` to be useful: under the
960
+ * previous rule a manual screen was readable for only 2000 ms, so wiring this alone
961
+ * would have handed interactions a name that expired before most taps happened.
962
+ */
963
+ try {
964
+ const {
965
+ AutoScreenDetector
966
+ } = require('../navigation/AutoScreenDetector');
967
+ AutoScreenDetector.getInstance().setManualScreen(name);
968
+ } catch {/* no-throw */}
969
+
682
970
  // Feed PerformanceFeature (screen context for perf metrics)
683
971
  // NOTE: Only sets screen identity. Does NOT call markScreenLoadStart/End.
684
972
  // AutoScreenLoadCollector handles timing independently via rAF + InteractionManager.
@@ -59,6 +59,34 @@ const ScaleBunScrollView = exports.ScaleBunScrollView = /*#__PURE__*/_react.defa
59
59
  }
60
60
  } catch {/* no-throw */}
61
61
 
62
+ /**
63
+ * RN-3 — record WHERE THIS scroller sits, keyed by its own native tag, so a later
64
+ * tap can say which content was under the finger.
65
+ *
66
+ * OUTSIDE the `isActive` guard above, deliberately. That guard is correct for the
67
+ * gesture tracker, which produces scroll EVENTS and should only do so while
68
+ * recording. This is a different thing: it is the position a tap needs in order to
69
+ * be placed in content rather than on glass, and a tap is captured on the always-on
70
+ * analytics lane even when no recording is active. Gating this the same way would
71
+ * make content depth absent in exactly the sessions the heatmap is built from.
72
+ *
73
+ * The tag is taken from the EVENT's target rather than from this component's ref,
74
+ * so it identifies the scroller that actually moved. With nested scrollers the
75
+ * event may come from an inner one, and keying by the wrapper would file the
76
+ * carousel's offset under the list.
77
+ */
78
+ try {
79
+ const {
80
+ recordScrollOffset,
81
+ nativeTagOf
82
+ } = require('./scrollContext');
83
+ const {
84
+ contentOffset,
85
+ target
86
+ } = event.nativeEvent;
87
+ recordScrollOffset(typeof target === 'number' ? target : nativeTagOf(target), contentOffset.x, contentOffset.y);
88
+ } catch {/* no-throw */}
89
+
62
90
  // Forward to user's onScroll handler
63
91
  if (userOnScroll) {
64
92
  userOnScroll(event);