@scalebun/react-native 1.10.7 → 1.11.1

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 (54) hide show
  1. package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -25
  2. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +15 -3
  3. package/dist/scalebun.full.js +467 -255
  4. package/dist/scalebun.slim.js +466 -254
  5. package/ios/Capture/InteractionTracker.swift +8 -4
  6. package/ios/Ota/OtaSlotManager.swift +19 -5
  7. package/lib/commonjs/analytics/EventTracker.js +5 -5
  8. package/lib/commonjs/analytics/automaticEvents.js +3 -2
  9. package/lib/commonjs/core/constants/version.js +7 -2
  10. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -103
  11. package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
  12. package/lib/commonjs/features/journey/uiState.js +8 -1
  13. package/lib/commonjs/features/ota/OtaOrchestrator.js +174 -48
  14. package/lib/commonjs/features/ota/OtaTypes.js +4 -0
  15. package/lib/commonjs/features/ota/useOtaUpdate.js +11 -2
  16. package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
  17. package/lib/commonjs/features/session/SessionManager.js +37 -38
  18. package/lib/commonjs/public/ScaleBunFacade.js +115 -2
  19. package/lib/module/analytics/EventTracker.js +5 -5
  20. package/lib/module/analytics/automaticEvents.js +3 -2
  21. package/lib/module/core/constants/version.js +7 -2
  22. package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -103
  23. package/lib/module/features/journey/interactionProtocol.js +38 -0
  24. package/lib/module/features/journey/uiState.js +8 -1
  25. package/lib/module/features/ota/OtaOrchestrator.js +174 -48
  26. package/lib/module/features/ota/OtaTypes.js +1 -1
  27. package/lib/module/features/ota/useOtaUpdate.js +11 -2
  28. package/lib/module/features/session/JourneyEventPipeline.js +6 -5
  29. package/lib/module/features/session/SessionManager.js +37 -38
  30. package/lib/module/public/ScaleBunFacade.js +115 -2
  31. package/lib/typescript/analytics/EventTracker.d.ts +1 -1
  32. package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
  33. package/lib/typescript/core/constants/version.d.ts +7 -2
  34. package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
  35. package/lib/typescript/features/ota/OtaEventEmitter.d.ts +15 -1
  36. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +22 -3
  37. package/lib/typescript/features/ota/OtaTypes.d.ts +29 -32
  38. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
  39. package/lib/typescript/features/session/SessionManager.d.ts +15 -10
  40. package/lib/typescript/public/ScaleBunFacade.d.ts +27 -0
  41. package/package.json +4 -3
  42. package/src/analytics/EventTracker.ts +5 -5
  43. package/src/analytics/automaticEvents.ts +4 -0
  44. package/src/core/constants/version.ts +7 -2
  45. package/src/features/journey/ScaleBunDebugRoot.tsx +96 -97
  46. package/src/features/journey/interactionProtocol.ts +65 -0
  47. package/src/features/journey/uiState.ts +9 -4
  48. package/src/features/ota/OtaEventEmitter.ts +12 -0
  49. package/src/features/ota/OtaOrchestrator.ts +209 -62
  50. package/src/features/ota/OtaTypes.ts +37 -39
  51. package/src/features/ota/useOtaUpdate.ts +11 -2
  52. package/src/features/session/JourneyEventPipeline.ts +7 -5
  53. package/src/features/session/SessionManager.ts +75 -38
  54. package/src/public/ScaleBunFacade.ts +127 -3
@@ -259,6 +259,11 @@ class ScaleBunFacade {
259
259
  _internalLogger.logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
260
260
  }
261
261
  }
262
+ // `healthyAfterMs` is the field BootGuardConfig actually declares.
263
+ // This passed `healthyTimeoutMs`, which nothing reads, so a host that
264
+ // tuned the boot-guard window silently got the 10s default instead.
265
+ // Both spellings are accepted so the older one keeps working.
266
+ const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
262
267
  otaOrchestrator.init({
263
268
  ...(signingRequested ? {
264
269
  signature: {
@@ -268,15 +273,123 @@ class ScaleBunFacade {
268
273
  verifier
269
274
  }
270
275
  } : {}),
271
- ...(typeof ota.healthyTimeoutMs === 'number' ? {
272
- healthyTimeoutMs: ota.healthyTimeoutMs
276
+ ...(healthyAfterMs !== undefined ? {
277
+ healthyAfterMs
273
278
  } : {})
274
279
  });
275
280
  _internalLogger.logger.info('[ScaleBun] OTA enabled from init config.');
281
+
282
+ // Run the checks the config asked for. Until this existed, `ota.enabled`
283
+ // initialised the orchestrator and then never checked anything: the
284
+ // documented `checkOnForeground` and `channelOverride` options were read
285
+ // by no code at all, and an app following the documented config received
286
+ // updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
287
+ // hand. Nothing logged, because "no update available" and "never asked"
288
+ // look identical from the outside.
289
+ this._startOtaChecks(ota);
276
290
  } catch (err) {
277
291
  _internalLogger.logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
278
292
  }
279
293
  }
294
+
295
+ /** Guards against overlapping config-driven OTA checks. */
296
+ _otaCheckInFlight = false;
297
+ /** Wall clock of the last config-driven check, for the foreground floor. */
298
+ _otaLastCheckAt = 0;
299
+ _otaForegroundListener = null;
300
+
301
+ /**
302
+ * Minimum gap between config-driven checks.
303
+ *
304
+ * A foreground transition is cheap to trigger — app switchers, permission
305
+ * dialogs and share sheets all produce one — so an unthrottled check would
306
+ * put a request on the hot path every time the user glanced away. Ten
307
+ * minutes is well below any realistic release cadence and well above that
308
+ * noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
309
+ * which is never throttled.
310
+ */
311
+ static OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
312
+
313
+ /**
314
+ * Drive OTA checks from init config: once at startup, then on each
315
+ * foreground when `checkOnForeground` is on (the schema default).
316
+ *
317
+ * `appVersion` is resolved from the native bridge rather than asked of the
318
+ * integrator, because it gates the server's `targetAppVersion` semver check
319
+ * — sending a wrong or invented value is worse than sending none, and there
320
+ * is no honest default. If it cannot be resolved, the check is skipped with
321
+ * a warning instead of guessing.
322
+ */
323
+ _startOtaChecks(ota) {
324
+ // Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
325
+ // there on purpose so Metro keeps ownership of the bundle — so a bundle
326
+ // downloaded in dev is installed into a slot that will never be loaded, and
327
+ // the identity check on the next launch then correctly observes that the
328
+ // running code is not what was installed and reports APPLY_FAILED. Checking
329
+ // at all in dev buys nothing and manufactures that false alarm. A developer
330
+ // testing the OTA path drives `useOtaUpdate().sync()` explicitly.
331
+ if (__DEV__) {
332
+ _internalLogger.logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
333
+ return;
334
+ }
335
+ const runCheck = async trigger => {
336
+ if (this._otaCheckInFlight) return;
337
+ if (trigger === 'foreground' && Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS) {
338
+ return;
339
+ }
340
+ const clientKey = this._clientKey;
341
+ const apiUrl = this._apiBaseUrl;
342
+ if (!clientKey || !apiUrl) return;
343
+ this._otaCheckInFlight = true;
344
+ try {
345
+ const info = await _bridgeAdapter.bridgeAdapter.getDeviceInfo();
346
+ const appVersion = info?.appVersion;
347
+ if (!appVersion) {
348
+ _internalLogger.logger.warn('[ScaleBun] OTA check skipped — the app version could not be read from the ' + 'native bridge. Rebuild the native app, or drive checks yourself with ' + 'useOtaUpdate({ appVersion }).');
349
+ return;
350
+ }
351
+ this._otaLastCheckAt = Date.now();
352
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
353
+ const {
354
+ otaOrchestrator
355
+ } = require('../features/ota/OtaOrchestrator');
356
+ await otaOrchestrator.sync({
357
+ apiUrl,
358
+ clientKey,
359
+ appVersion,
360
+ // The documented option, finally connected. Omitted means the
361
+ // server's `default` channel, exactly as before.
362
+ channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined
363
+ // Never forced from config: the release's own installMode
364
+ // decides when the app restarts, and yanking the screen out
365
+ // from under a user is not a decision this switch should make.
366
+ });
367
+ } catch (err) {
368
+ _internalLogger.logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
369
+ } finally {
370
+ this._otaCheckInFlight = false;
371
+ }
372
+ };
373
+ void runCheck('startup');
374
+ if (ota.checkOnForeground === false) return;
375
+ if (this._otaForegroundListener) return; // idempotent across repeated init()
376
+ try {
377
+ // Through `appLifecycle`, not a second AppState subscription: the SDK
378
+ // already owns one and fanning out from it keeps every consumer on the
379
+ // same transition sequence.
380
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
381
+ const {
382
+ appLifecycle
383
+ } = require('../core/lifecycle/appLifecycle');
384
+ this._otaForegroundListener = state => {
385
+ if (state === 'active') void runCheck('foreground');
386
+ };
387
+ appLifecycle.addListener(this._otaForegroundListener);
388
+ } catch {
389
+ // Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
390
+ this._otaForegroundListener = null;
391
+ }
392
+ }
280
393
  _autoEnableDebug(debugConfig) {
281
394
  try {
282
395
  const dbgConfig = (0, _debug.buildDebugConfigFromInitConfig)(debugConfig);
@@ -98,7 +98,7 @@ export class EventTracker {
98
98
  this.started = true;
99
99
  if (this.cfg.automaticEventTracking) {
100
100
  this.automaticEventsUnsubscribe = subscribeAutomaticEvents(event => {
101
- this.track(event.name, event.properties);
101
+ this.track(event.name, event.properties, event.timestamp);
102
102
  });
103
103
  }
104
104
  if (this.cfg.autoLifecycleEvents) {
@@ -221,9 +221,9 @@ export class EventTracker {
221
221
 
222
222
  // ─── tracking ────────────────────────────────────────────────────────────
223
223
 
224
- track(eventName, properties) {
224
+ track(eventName, properties, timestamp) {
225
225
  try {
226
- this.enqueue(this.buildEnvelope(eventName, properties));
226
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
227
227
  try {
228
228
  this.cfg.onEvent?.(eventName);
229
229
  } catch {/* no-throw */}
@@ -360,7 +360,7 @@ export class EventTracker {
360
360
 
361
361
  // ─── internals ─────────────────────────────────────────────────────────────
362
362
 
363
- buildEnvelope(eventName, properties) {
363
+ buildEnvelope(eventName, properties, timestamp) {
364
364
  const ctx = this.cfg.context ?? {};
365
365
  let canonicalSessionId;
366
366
  try {
@@ -371,7 +371,7 @@ export class EventTracker {
371
371
  const env = {
372
372
  event_id: uuid(),
373
373
  event_name: eventName,
374
- event_time: Date.now(),
374
+ event_time: timestamp ?? Date.now(),
375
375
  app_id: this.cfg.appId,
376
376
  platform: this.cfg.platform ?? resolveEventPlatform(),
377
377
  installation_id: this.installationId,
@@ -19,10 +19,11 @@ function compactProperties(properties) {
19
19
  }
20
20
  return out;
21
21
  }
22
- export function emitAutomaticEvent(name, properties) {
22
+ export function emitAutomaticEvent(name, properties, timestamp) {
23
23
  const event = {
24
24
  name,
25
- properties: compactProperties(properties)
25
+ properties: compactProperties(properties),
26
+ timestamp
26
27
  };
27
28
  if (listeners.size === 0) {
28
29
  pending.push(event);
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export const SDK_VERSION = '1.10.7';
11
+ export const SDK_VERSION = '1.11.1';
7
12
  //# sourceMappingURL=version.js.map
@@ -22,9 +22,9 @@
22
22
 
23
23
  import React, { useEffect, useRef, createContext, useContext } from 'react';
24
24
  import { View, NativeEventEmitter, NativeModules } from 'react-native';
25
- import { emitAutomaticEvent } from '../../analytics/automaticEvents';
26
25
  import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
27
26
  import { resolvePlatformOS } from '../../core/context/device';
27
+ import { INTERACTION_PROTOCOL_VERSION, generateInteractionId, nearestInteractionStart } from './interactionProtocol';
28
28
 
29
29
  // ─── Lazy imports to avoid native module cascade at load time ────────────────
30
30
  // We only import TYPE references at the top level; actual modules are require()'d
@@ -256,7 +256,9 @@ export function ScaleBunDebugRoot({
256
256
  // and forwards them to SessionManager as USER_ACTION events.
257
257
  const lastNativeTouchTsRef = useRef(0);
258
258
  const nativeTrackingConfirmedRef = useRef(false);
259
- const pendingJsEmitRef = useRef(null);
259
+ const touchStartRef = useRef(null);
260
+ const interactionStartsRef = useRef([]);
261
+ const pendingJsEmitsRef = useRef(new Map());
260
262
  useEffect(() => {
261
263
  let subscription = null;
262
264
  try {
@@ -268,11 +270,15 @@ export function ScaleBunDebugRoot({
268
270
  // Record timestamp to suppress duplicate JS touch events
269
271
  lastNativeTouchTsRef.current = Date.now();
270
272
  nativeTrackingConfirmedRef.current = true;
271
-
272
- // Cancel any pending JS emission — native event wins
273
- if (pendingJsEmitRef.current !== null) {
274
- clearTimeout(pendingJsEmitRef.current);
275
- pendingJsEmitRef.current = null;
273
+ const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
274
+ const start = nearestInteractionStart(interactionStartsRef.current, nativeOccurredAt);
275
+
276
+ // Cancel only this physical touch's fallback. A single timer used to cancel a
277
+ // previous rapid tap and silently lose it on devices without native tracking.
278
+ if (start) {
279
+ const pending = pendingJsEmitsRef.current.get(start.interactionId);
280
+ if (pending !== undefined) clearTimeout(pending);
281
+ pendingJsEmitsRef.current.delete(start.interactionId);
276
282
  }
277
283
  const {
278
284
  SessionManager
@@ -294,6 +300,15 @@ export function ScaleBunDebugRoot({
294
300
  _diagWinH = _d.height;
295
301
  } catch {/* no-throw */}
296
302
  sm.onGestureDetected(event.gestureType || 'tap', {
303
+ interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
304
+ interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
305
+ occurredAt: nativeOccurredAt,
306
+ ui: start?.ui,
307
+ stateStatus: start?.stateStatus ?? 'not_captured',
308
+ target: start?.target,
309
+ targetId: start?.targetId,
310
+ screenName: start?.screenName,
311
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
297
312
  x: event.rawX,
298
313
  y: event.rawY,
299
314
  // Use the actual native end coords when present, not the down
@@ -323,11 +338,12 @@ export function ScaleBunDebugRoot({
323
338
  try {
324
339
  subscription?.remove();
325
340
  } catch {/* no-throw */}
341
+ for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
342
+ pendingJsEmitsRef.current.clear();
326
343
  };
327
- }, []);
344
+ }, [captureAutomaticInteractions]);
328
345
 
329
346
  // ─── Touch Tracking State ───────────────────────────────────────────
330
- const touchStartRef = useRef(null);
331
347
 
332
348
  // On-screen rect of the root view — the EXACT view the native screenshot
333
349
  // captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
@@ -371,11 +387,37 @@ export function ScaleBunDebugRoot({
371
387
  const handleTouchStart = e => {
372
388
  try {
373
389
  const touch = e.nativeEvent;
374
- touchStartRef.current = {
390
+ const target = resolveTouchTarget(e);
391
+ let ui;
392
+ let screenName;
393
+ try {
394
+ const {
395
+ uiStateSignature
396
+ } = require('./uiState');
397
+ ui = uiStateSignature();
398
+ } catch {/* no-throw */}
399
+ try {
400
+ const {
401
+ AutoScreenDetector
402
+ } = require('../navigation/AutoScreenDetector');
403
+ screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
404
+ } catch {/* no-throw */}
405
+ const start = {
406
+ interactionId: generateInteractionId(),
407
+ occurredAt: Date.now(),
375
408
  x: touch.pageX,
376
409
  y: touch.pageY,
377
- ts: Date.now()
410
+ locationX: touch.locationX,
411
+ locationY: touch.locationY,
412
+ target: describeTouchTarget(target),
413
+ targetId: target?.testID,
414
+ screenName,
415
+ ui,
416
+ stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
417
+ emitAutomaticAnalytics: captureAutomaticInteractions
378
418
  };
419
+ touchStartRef.current = start;
420
+ interactionStartsRef.current = interactionStartsRef.current.filter(candidate => start.occurredAt - candidate.occurredAt < 5000).concat(start).slice(-8);
379
421
  } catch {/* no-throw */}
380
422
  };
381
423
  const handleTouchEnd = e => {
@@ -414,15 +456,23 @@ export function ScaleBunDebugRoot({
414
456
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
415
457
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
416
458
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
417
- const tapped = describeTouchTarget(resolveTouchTarget(e));
459
+ const tapped = start?.target ?? describeTouchTarget(resolveTouchTarget(e));
418
460
  const gestureDetails = {
419
461
  target: tapped,
420
- x: touch.pageX,
421
- y: touch.pageY,
422
- pageX: touch.pageX,
423
- pageY: touch.pageY,
424
- locationX: touch.locationX,
425
- locationY: touch.locationY,
462
+ interactionId: start?.interactionId ?? generateInteractionId(),
463
+ interactionProtocol: INTERACTION_PROTOCOL_VERSION,
464
+ occurredAt: start?.occurredAt ?? Date.now(),
465
+ ui: start?.ui,
466
+ stateStatus: start?.stateStatus ?? 'not_captured',
467
+ targetId: start?.targetId,
468
+ screenName: start?.screenName,
469
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
470
+ x: start?.x ?? touch.pageX,
471
+ y: start?.y ?? touch.pageY,
472
+ pageX: start?.x ?? touch.pageX,
473
+ pageY: start?.y ?? touch.pageY,
474
+ locationX: start?.locationX ?? touch.locationX,
475
+ locationY: start?.locationY ?? touch.locationY,
426
476
  viewportWidth: vpW > 0 ? vpW : undefined,
427
477
  viewportHeight: vpH > 0 ? vpH : undefined
428
478
  };
@@ -434,8 +484,8 @@ export function ScaleBunDebugRoot({
434
484
  // measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
435
485
  // double-counts the status bar and pushes every marker upward.
436
486
  if (rootRect && rootRect.w > 0 && rootRect.h > 0) {
437
- const nx = Math.max(0, Math.min(1, touch.pageX / rootRect.w));
438
- const ny = Math.max(0, Math.min(1, touch.pageY / rootRect.h));
487
+ const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
488
+ const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
439
489
  gestureDetails.normalizedX = nx;
440
490
  gestureDetails.normalizedY = ny;
441
491
  gestureDetails.normalizedPrecomputed = true;
@@ -467,7 +517,7 @@ export function ScaleBunDebugRoot({
467
517
  const dx = touch.pageX - start.x;
468
518
  const dy = touch.pageY - start.y;
469
519
  const dist = Math.sqrt(dx * dx + dy * dy);
470
- const duration = Date.now() - start.ts;
520
+ const duration = Date.now() - start.occurredAt;
471
521
  gestureDetails.duration = duration;
472
522
  if (dist >= 15) {
473
523
  // Movement gesture: swipe or scroll
@@ -486,52 +536,6 @@ export function ScaleBunDebugRoot({
486
536
  gestureType = 'long_press';
487
537
  }
488
538
  }
489
- const targetInfo = _extractTarget(e);
490
- gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
491
- if (captureAutomaticInteractions) {
492
- let screen;
493
- try {
494
- const {
495
- AutoScreenDetector
496
- } = require('../navigation/AutoScreenDetector');
497
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
498
- } catch {/* no-throw */}
499
- /**
500
- * UI STATE, read HERE and not later.
501
- *
502
- * A tap belongs to the surface that was on screen when the finger landed: tapping the
503
- * filter button while the sheet is DOWN belongs to `closed`, because that is what the
504
- * user was looking at when they reached for it. Reading it after the handler has run
505
- * moves every "open the thing" tap into the state it created — the one state it
506
- * certainly does not belong to.
507
- *
508
- * Declared-only on this platform (see uiState.ts): absent means not captured, which
509
- * the dashboard keeps distinct from "nothing was open".
510
- */
511
- let ui;
512
- try {
513
- const {
514
- uiStateSignature
515
- } = require('./uiState');
516
- ui = uiStateSignature();
517
- } catch {/* no-throw: a tap must never be lost to state capture */}
518
- emitAutomaticEvent('element_interacted', {
519
- gesture_type: gestureType,
520
- screen_name: screen,
521
- /* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
522
- backend as undefined with no error anywhere — the recurring defect class in this
523
- codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
524
- the web SDK's, so one dashboard control queries both platforms. */
525
- ui,
526
- // testID/nativeID is an author-controlled stable identifier.
527
- // Accessibility labels and rendered text are deliberately omitted.
528
- target_id: targetInfo?.testId,
529
- normalized_x: gestureDetails.normalizedX,
530
- normalized_y: gestureDetails.normalizedY,
531
- direction: gestureDetails.direction,
532
- duration_ms: gestureDetails.duration
533
- });
534
- }
535
539
 
536
540
  // Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
537
541
  // taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
@@ -541,18 +545,11 @@ export function ScaleBunDebugRoot({
541
545
  const {
542
546
  gestureTriggerDetector
543
547
  } = require('../engage/gestureTriggerDetector');
544
- let screen;
545
- try {
546
- const {
547
- AutoScreenDetector
548
- } = require('../navigation/AutoScreenDetector');
549
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
550
- } catch {/* no-throw */}
551
548
  gestureTriggerDetector.recordTap({
552
- x: touch.pageX,
553
- y: touch.pageY,
554
- hasTarget: !!(targetInfo?.testId || targetInfo?.accessibilityLabel),
555
- screen
549
+ x: start?.x ?? touch.pageX,
550
+ y: start?.y ?? touch.pageY,
551
+ hasTarget: !!(start?.targetId || start?.target),
552
+ screen: start?.screenName
556
553
  });
557
554
  } catch {/* no-throw */}
558
555
  }
@@ -567,7 +564,7 @@ export function ScaleBunDebugRoot({
567
564
  // screenshot capture view). Three-tier suppression:
568
565
  // 1. Native confirmed active → skip JS entirely (native handles all touches)
569
566
  // 2. Native event within 300ms → skip (existing dedup)
570
- // 3. First touch (native not yet confirmed) → defer 200ms to let native arrive
567
+ // 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
571
568
  const sinceNative = Date.now() - lastNativeTouchTsRef.current;
572
569
  if (nativeTrackingConfirmedRef.current) {
573
570
  // Native tracker is active — skip JS path entirely
@@ -580,11 +577,9 @@ export function ScaleBunDebugRoot({
580
577
  const capturedDetails = {
581
578
  ...gestureDetails
582
579
  };
583
- if (pendingJsEmitRef.current !== null) {
584
- clearTimeout(pendingJsEmitRef.current);
585
- }
586
- pendingJsEmitRef.current = setTimeout(() => {
587
- pendingJsEmitRef.current = null;
580
+ const interactionId = capturedDetails.interactionId;
581
+ const timer = setTimeout(() => {
582
+ pendingJsEmitsRef.current.delete(interactionId);
588
583
  if (nativeTrackingConfirmedRef.current) return;
589
584
  try {
590
585
  const {
@@ -608,6 +603,7 @@ export function ScaleBunDebugRoot({
608
603
  // devices with no native tracker at all it merely delays a background emission,
609
604
  // which nothing user-visible waits on.
610
605
  }, 600);
606
+ pendingJsEmitsRef.current.set(interactionId, timer);
611
607
  }
612
608
  touchStartRef.current = null;
613
609
  } catch {/* no-throw */}
@@ -722,25 +718,6 @@ export function useScaleBunScreen(name) {
722
718
  }, [name, manager]);
723
719
  }
724
720
 
725
- // ─── Helpers ────────────────────────────────────────────────────────────────
726
-
727
- function _extractTarget(e) {
728
- try {
729
- const target = e?.target;
730
- if (!target) return undefined;
731
-
732
- // React Native nativeEvent target properties
733
- const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
734
- return {
735
- testId: props.testID || props.nativeID || undefined,
736
- accessibilityLabel: props.accessibilityLabel || undefined,
737
- text: undefined
738
- };
739
- } catch {
740
- return undefined;
741
- }
742
- }
743
-
744
721
  // ─── Styles ─────────────────────────────────────────────────────────────────
745
722
  // IMPORTANT: Do NOT use StyleSheet.create() here.
746
723
  // In RN 0.84 bridgeless mode, StyleSheet.create() calls
@@ -0,0 +1,38 @@
1
+ /** One physical interaction, shared by replay and analytics projections. */
2
+ export const INTERACTION_PROTOCOL_VERSION = 1;
3
+ export function generateInteractionId() {
4
+ return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
5
+ }
6
+
7
+ /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
8
+ export function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
9
+ let best;
10
+ let bestDelta = toleranceMs + 1;
11
+ for (const start of starts) {
12
+ const delta = Math.abs(start.occurredAt - occurredAt);
13
+ if (delta < bestDelta) {
14
+ best = start;
15
+ bestDelta = delta;
16
+ }
17
+ }
18
+ return bestDelta <= toleranceMs ? best : undefined;
19
+ }
20
+
21
+ /** Analytics is a projection of the same evidence; no second click is invented. */
22
+ export function automaticInteractionProperties(payload, screenName, canonicalMirror) {
23
+ return {
24
+ gesture_type: payload.gestureType,
25
+ screen_name: screenName,
26
+ interaction_id: payload.interaction_id,
27
+ interaction_protocol: payload.interaction_protocol,
28
+ state_status: payload.state_status,
29
+ ui: payload.ui,
30
+ target_id: payload.target_id,
31
+ normalized_x: payload.normalizedX,
32
+ normalized_y: payload.normalizedY,
33
+ direction: payload.direction,
34
+ duration_ms: payload.durationMs,
35
+ canonical_mirror: canonicalMirror || undefined
36
+ };
37
+ }
38
+ //# sourceMappingURL=interactionProtocol.js.map
@@ -74,6 +74,13 @@ export function uiStateSignature() {
74
74
  if (!declared.size) return undefined;
75
75
  /* SORTED, or the same surface produces different signatures depending on the order the host happened
76
76
  to declare things in, and every count fragments into several that mean nothing. */
77
- return [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`).join(';').slice(0, 96);
77
+ const pairs = [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`);
78
+ let signature = '';
79
+ for (const pair of pairs) {
80
+ const next = signature ? `${signature};${pair}` : pair;
81
+ if (next.length > 96) break;
82
+ signature = next;
83
+ }
84
+ return signature || undefined;
78
85
  }
79
86
  //# sourceMappingURL=uiState.js.map