@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.
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +18 -3
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +2 -0
- package/dist/scalebun.full.js +840 -114
- package/dist/scalebun.slim.js +838 -112
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +16 -0
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/ota/OtaOrchestrator.js +69 -22
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +18 -16
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +16 -0
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/ota/OtaOrchestrator.js +69 -22
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +18 -16
- package/lib/typescript/core/config/schema.d.ts +2 -0
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +7 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
- package/package.json +2 -2
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +18 -0
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/ota/OtaOrchestrator.ts +76 -22
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +18 -16
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +3 -0
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
import React, { useEffect, useRef, createContext, useContext } from 'react';
|
|
24
24
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
25
|
-
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
25
|
+
import { resolveTouchTarget, describeTouchTarget, targetIdentity } from './touchTarget';
|
|
26
|
+
import { localPoint, normalizeRect, readRectSync } from './targetGeometry';
|
|
27
|
+
import { nativeTagOf, nearestRecordedOffset } from './scrollContext';
|
|
26
28
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
27
|
-
import { INTERACTION_PROTOCOL_VERSION, generateInteractionId,
|
|
29
|
+
import { INTERACTION_PROTOCOL_VERSION, generateInteractionId, correlateInteraction, pruneInteractionStarts } from './interactionProtocol';
|
|
28
30
|
|
|
29
31
|
// ─── Lazy imports to avoid native module cascade at load time ────────────────
|
|
30
32
|
// We only import TYPE references at the top level; actual modules are require()'d
|
|
@@ -42,6 +44,15 @@ function getBootstrap() {
|
|
|
42
44
|
|
|
43
45
|
const JourneyContext = /*#__PURE__*/createContext(null);
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Floor on how often a UI-state change may request a screenshot.
|
|
49
|
+
*
|
|
50
|
+
* One frame per second is far finer than any state a person perceives changing, and far coarser
|
|
51
|
+
* than a render loop — which is the case that matters, because a host may legitimately declare
|
|
52
|
+
* state from inside render and an oscillating dimension would otherwise capture continuously.
|
|
53
|
+
*/
|
|
54
|
+
const UI_STATE_CAPTURE_MIN_INTERVAL_MS = 1000;
|
|
55
|
+
|
|
45
56
|
// ─── ScaleBunDebugRoot ───────────────────────────────────────────────────────────
|
|
46
57
|
|
|
47
58
|
export function ScaleBunDebugRoot({
|
|
@@ -251,6 +262,54 @@ export function ScaleBunDebugRoot({
|
|
|
251
262
|
};
|
|
252
263
|
}, [navigationRef]);
|
|
253
264
|
|
|
265
|
+
/**
|
|
266
|
+
* RN-11 — CAPTURE A FRAME WHEN THE UI CHANGES SHAPE.
|
|
267
|
+
*
|
|
268
|
+
* A declared state used to have no picture of its own. Nothing captured on `setUiState`, so a
|
|
269
|
+
* state's visual existed only if a screenshot happened to be taken for another reason — a
|
|
270
|
+
* navigation, a timer, or the interaction capture that fires AFTER the touch and therefore
|
|
271
|
+
* photographs the outcome. A drawer could be recorded as open with no frame showing it open,
|
|
272
|
+
* or with the frame of the screen that replaced it.
|
|
273
|
+
*
|
|
274
|
+
* The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER; none of them means
|
|
275
|
+
* "the UI changed shape". This routes through the MANUAL path, which is the only one reachable
|
|
276
|
+
* from JS. A dedicated native reason would let the backend tell a state capture from a
|
|
277
|
+
* developer-requested one, and that is a native change on both platforms — worth doing, not
|
|
278
|
+
* doable from here.
|
|
279
|
+
*
|
|
280
|
+
* RATE LIMITED, and the limit is not politeness. A screenshot is expensive, and while
|
|
281
|
+
* `uiState` already collapses redundant declarations, a genuinely oscillating dimension (a
|
|
282
|
+
* spinner toggling, a live-updating badge) would otherwise capture continuously. One frame per
|
|
283
|
+
* second is far finer than any state a person can perceive changing and far coarser than a
|
|
284
|
+
* render loop.
|
|
285
|
+
*
|
|
286
|
+
* Requests only; never awaits and never throws. If the session is not recording,
|
|
287
|
+
* `captureManualFrame` declines and the state simply has no frame — which is the honest
|
|
288
|
+
* outcome and is exactly what the analytics-only lane already reports elsewhere.
|
|
289
|
+
*/
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
let lastCaptureTs = 0;
|
|
292
|
+
const {
|
|
293
|
+
onUiStateChange
|
|
294
|
+
} = require('./uiState');
|
|
295
|
+
const unsubscribe = onUiStateChange(() => {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
if (now - lastCaptureTs < UI_STATE_CAPTURE_MIN_INTERVAL_MS) return;
|
|
298
|
+
lastCaptureTs = now;
|
|
299
|
+
try {
|
|
300
|
+
const {
|
|
301
|
+
SessionManager
|
|
302
|
+
} = require('../session/SessionManager');
|
|
303
|
+
const sm = SessionManager.getExistingInstance();
|
|
304
|
+
if (sm && typeof sm.captureManualFrame === 'function') {
|
|
305
|
+
/* Fire and forget: the tap that follows must not wait on a screenshot. */
|
|
306
|
+
void sm.captureManualFrame();
|
|
307
|
+
}
|
|
308
|
+
} catch {/* no-throw */}
|
|
309
|
+
});
|
|
310
|
+
return unsubscribe;
|
|
311
|
+
}, []);
|
|
312
|
+
|
|
254
313
|
// ─── Native Interaction Listener ────────────────────────────────────
|
|
255
314
|
// Receives high-confidence ScaleBunNativeInteraction events from the native tracker
|
|
256
315
|
// and forwards them to SessionManager as USER_ACTION events.
|
|
@@ -271,7 +330,27 @@ export function ScaleBunDebugRoot({
|
|
|
271
330
|
lastNativeTouchTsRef.current = Date.now();
|
|
272
331
|
nativeTrackingConfirmedRef.current = true;
|
|
273
332
|
const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
|
|
274
|
-
|
|
333
|
+
/**
|
|
334
|
+
* RN-6 — correlate, then CONSUME. See interactionProtocol.ts for why
|
|
335
|
+
* the join is on time at all, and why a refusal is the important part.
|
|
336
|
+
*
|
|
337
|
+
* Pruning first is part of the correctness, not tidying: a start older
|
|
338
|
+
* than the tolerance can never be paired, but while it sits in the list
|
|
339
|
+
* it is still counted by the ambiguity test, so a stale entry can turn a
|
|
340
|
+
* clear pairing into a refusal — and the list only grows.
|
|
341
|
+
*/
|
|
342
|
+
pruneInteractionStarts(interactionStartsRef.current, nativeOccurredAt);
|
|
343
|
+
const correlation = correlateInteraction(interactionStartsRef.current, nativeOccurredAt);
|
|
344
|
+
const start = correlation.start;
|
|
345
|
+
/**
|
|
346
|
+
* ONE-TO-ONE. Without removing the matched start, a second native event
|
|
347
|
+
* arriving within the window claims the SAME js evidence, so one tap's
|
|
348
|
+
* target is stamped onto another tap that never had one — a duplicate
|
|
349
|
+
* identity that reads as two genuine interactions on the same control.
|
|
350
|
+
*/
|
|
351
|
+
if (correlation.index >= 0) {
|
|
352
|
+
interactionStartsRef.current.splice(correlation.index, 1);
|
|
353
|
+
}
|
|
275
354
|
|
|
276
355
|
// Cancel only this physical touch's fallback. A single timer used to cancel a
|
|
277
356
|
// previous rapid tap and silently lose it on devices without native tracking.
|
|
@@ -303,11 +382,62 @@ export function ScaleBunDebugRoot({
|
|
|
303
382
|
interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
|
|
304
383
|
interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
|
|
305
384
|
occurredAt: nativeOccurredAt,
|
|
385
|
+
/**
|
|
386
|
+
* RN-6 — THESE FIVE FIELDS ARE THE ONLY ONES THE JOIN CARRIES, AND
|
|
387
|
+
* THEREFORE THE ONLY ONES A BAD JOIN CAN FALSIFY.
|
|
388
|
+
*
|
|
389
|
+
* Coordinates below come from native and are right regardless. So a
|
|
390
|
+
* mispairing does not produce visibly broken data: it produces heat
|
|
391
|
+
* in the correct position attributed to the wrong control, in the
|
|
392
|
+
* wrong UI state, on the wrong screen. Every field is individually
|
|
393
|
+
* well-formed and nothing downstream can tell.
|
|
394
|
+
*
|
|
395
|
+
* When the correlation is not 'exact' they are left UNDEFINED rather
|
|
396
|
+
* than filled from a best guess. An interaction with no target is
|
|
397
|
+
* honest and visibly incomplete — the accounting lane can count it as
|
|
398
|
+
* unplaceable and say why. An interaction with the WRONG target is
|
|
399
|
+
* indistinguishable from a correct one and silently corrupts every
|
|
400
|
+
* aggregate it enters. `null` is not `0`, and a guess is not a
|
|
401
|
+
* measurement.
|
|
402
|
+
*/
|
|
306
403
|
ui: start?.ui,
|
|
307
404
|
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
308
405
|
target: start?.target,
|
|
309
406
|
targetId: start?.targetId,
|
|
407
|
+
targetSource: start?.targetSource,
|
|
310
408
|
screenName: start?.screenName,
|
|
409
|
+
screenSource: start?.screenSource ?? 'none',
|
|
410
|
+
targetRect: start?.targetRect,
|
|
411
|
+
/**
|
|
412
|
+
* RN-15 — WHERE IN THE CONTROL, computed where both halves exist: the
|
|
413
|
+
* point comes from the native event and the rect was measured at
|
|
414
|
+
* finger-down, in the same capture base. Pairing them here rather than
|
|
415
|
+
* downstream stops anything later reaching for a different rect.
|
|
416
|
+
*/
|
|
417
|
+
...(() => {
|
|
418
|
+
const lp = localPoint(start?.targetRect ?? null, event.normalizedX, event.normalizedY);
|
|
419
|
+
return lp ? {
|
|
420
|
+
localU: lp.u,
|
|
421
|
+
localV: lp.v
|
|
422
|
+
} : {};
|
|
423
|
+
})(),
|
|
424
|
+
scrollX: start?.scrollX,
|
|
425
|
+
scrollY: start?.scrollY,
|
|
426
|
+
scrollSource: start?.scrollSource ?? 'none',
|
|
427
|
+
itemKey: start?.itemKey,
|
|
428
|
+
itemIndex: start?.itemIndex,
|
|
429
|
+
/**
|
|
430
|
+
* The join's own verdict, shipped with the row so it is auditable
|
|
431
|
+
* rather than a claim made in a comment. 'ambiguous' and 'none' are
|
|
432
|
+
* deliberately distinguished: 'none' means the JS handler never fired
|
|
433
|
+
* (a real capture gap on this platform), 'ambiguous' means it fired
|
|
434
|
+
* more than once too close together to attribute. Those have
|
|
435
|
+
* different fixes, and collapsing them would hide which one is
|
|
436
|
+
* actually happening in production.
|
|
437
|
+
*/
|
|
438
|
+
correlation: correlation.outcome,
|
|
439
|
+
correlationDeltaMs: correlation.deltaMs,
|
|
440
|
+
correlationRunnerUpDeltaMs: correlation.runnerUpDeltaMs,
|
|
311
441
|
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
312
442
|
x: event.rawX,
|
|
313
443
|
y: event.rawY,
|
|
@@ -390,6 +520,16 @@ export function ScaleBunDebugRoot({
|
|
|
390
520
|
const target = resolveTouchTarget(e);
|
|
391
521
|
let ui;
|
|
392
522
|
let screenName;
|
|
523
|
+
/**
|
|
524
|
+
* RN-8 — WHERE the screen name came from, carried with the interaction.
|
|
525
|
+
*
|
|
526
|
+
* Production shows 13.8% of Android interactions with a screen and 0.5% on iOS, and
|
|
527
|
+
* those figures cannot distinguish three unrelated causes: nothing was instrumented,
|
|
528
|
+
* a manual beacon was instrumented but never reached interactions, or auto-detection
|
|
529
|
+
* ran and failed. Each has a different owner. Recording the source turns one
|
|
530
|
+
* unactionable percentage into three answerable ones.
|
|
531
|
+
*/
|
|
532
|
+
let screenSource = 'none';
|
|
393
533
|
try {
|
|
394
534
|
const {
|
|
395
535
|
uiStateSignature
|
|
@@ -400,7 +540,12 @@ export function ScaleBunDebugRoot({
|
|
|
400
540
|
const {
|
|
401
541
|
AutoScreenDetector
|
|
402
542
|
} = require('../navigation/AutoScreenDetector');
|
|
403
|
-
|
|
543
|
+
const detector = AutoScreenDetector.getInstance();
|
|
544
|
+
screenName = detector.getCurrentScreen() || undefined;
|
|
545
|
+
/* Read from the SAME call site as the name, so the two can never describe
|
|
546
|
+
different moments — the detector is polled and its answer can change between
|
|
547
|
+
two reads a few milliseconds apart. */
|
|
548
|
+
screenSource = screenName ? detector.getCurrentScreenSource() : 'none';
|
|
404
549
|
} catch {/* no-throw */}
|
|
405
550
|
const start = {
|
|
406
551
|
interactionId: generateInteractionId(),
|
|
@@ -409,9 +554,93 @@ export function ScaleBunDebugRoot({
|
|
|
409
554
|
y: touch.pageY,
|
|
410
555
|
locationX: touch.locationX,
|
|
411
556
|
locationY: touch.locationY,
|
|
557
|
+
/**
|
|
558
|
+
* RN-1 + RN-2 — the identity that is persisted, and it is now code-derived.
|
|
559
|
+
*
|
|
560
|
+
* `describeTouchTarget` returns `testID` -> structural path -> component name,
|
|
561
|
+
* and never a caption or an accessibilityLabel. Before RN-2 it preferred free
|
|
562
|
+
* text, so this line was writing user data ("Pay EGP 1,442", an order row
|
|
563
|
+
* labelled with an email address, a contact's name) into the analytics store —
|
|
564
|
+
* and, because `target` is an aggregation key, splitting one button into one
|
|
565
|
+
* bucket per person at the same time.
|
|
566
|
+
*
|
|
567
|
+
* `targetId` used to be `target?.testID` alone, which is `undefined` in the
|
|
568
|
+
* overwhelming majority of apps — no testIDs authored — so the field the
|
|
569
|
+
* backend keys on was empty almost always. It now falls back to the structural
|
|
570
|
+
* path, with `targetSource` recording WHICH it got.
|
|
571
|
+
*/
|
|
412
572
|
target: describeTouchTarget(target),
|
|
413
|
-
targetId: target
|
|
573
|
+
targetId: targetIdentity(target).id,
|
|
574
|
+
/**
|
|
575
|
+
* WHICH KIND of identity this is, because they are not equally trustworthy: a
|
|
576
|
+
* `testID` is a promise from the app's authors that this control is this
|
|
577
|
+
* control; a `path` is an inference that breaks when the tree is refactored.
|
|
578
|
+
* Without this the accounting lane cannot distinguish "the control had no
|
|
579
|
+
* identity" from "the identity is a structural guess", and both would render
|
|
580
|
+
* as an equally confident target.
|
|
581
|
+
*/
|
|
582
|
+
targetSource: targetIdentity(target).source,
|
|
583
|
+
/* RN-4 — row identity from the app's own keyExtractor; see touchTarget.ts. */
|
|
584
|
+
itemKey: target?.itemKey,
|
|
585
|
+
itemIndex: target?.itemIndex,
|
|
586
|
+
/**
|
|
587
|
+
* RN-13 — the control's rectangle, so `heat ∈ target` is decidable.
|
|
588
|
+
*
|
|
589
|
+
* Read SYNCHRONOUSLY here, in the same tick as the touch, and normalized against
|
|
590
|
+
* the SAME measured root the tap's own coordinates use. Both halves matter:
|
|
591
|
+
*
|
|
592
|
+
* - A callback measurement (`measureInWindow`) would resolve after this handler
|
|
593
|
+
* returns, so the rect attached to a tap could be the previous tap's, with
|
|
594
|
+
* nothing afterwards able to tell. `targetGeometry` refuses to guess: it reads
|
|
595
|
+
* now or reports null.
|
|
596
|
+
* - Normalizing against a window size instead of the root view is off by the
|
|
597
|
+
* status bar on most devices, which would read as "the heat is just outside
|
|
598
|
+
* the button" everywhere rather than as a measurement bug.
|
|
599
|
+
*
|
|
600
|
+
* `measureRoot()` is called first because the cached root rect is populated
|
|
601
|
+
* asynchronously at mount; without it the first taps of a session normalize
|
|
602
|
+
* against nothing and get a null rect for a reason that has nothing to do with
|
|
603
|
+
* the control.
|
|
604
|
+
*/
|
|
605
|
+
/**
|
|
606
|
+
* RN-3 — WHERE IN THE CONTENT, not just where on the glass.
|
|
607
|
+
*
|
|
608
|
+
* Two taps at the same screen coordinate one viewport apart are two different
|
|
609
|
+
* controls. Without an offset they merge into one hotspot, and the heatmap shows
|
|
610
|
+
* heavy engagement at a position where, in content terms, nothing in particular
|
|
611
|
+
* is. The offset is also a CACHE dimension — two scroll states of one route must
|
|
612
|
+
* not share a backdrop — so its absence corrupts the picture as well as the count.
|
|
613
|
+
*
|
|
614
|
+
* `ancestors` is innermost-first, so this takes the NEAREST scroller that has a
|
|
615
|
+
* recorded position: a tap inside a horizontal carousel belongs to the carousel's
|
|
616
|
+
* content, not to how far the page behind it had scrolled.
|
|
617
|
+
*
|
|
618
|
+
* Null when no ancestor has one — never zero. Zero is a real scroll position (the
|
|
619
|
+
* top of a list), and reporting it for "unknown" would place every unmeasured tap
|
|
620
|
+
* at the top of its content, indistinguishable from a genuine tap there.
|
|
621
|
+
*/
|
|
622
|
+
...(() => {
|
|
623
|
+
const hit = nearestRecordedOffset((target?.ancestors ?? []).map(n => nativeTagOf(n)));
|
|
624
|
+
return hit ? {
|
|
625
|
+
scrollX: hit.x,
|
|
626
|
+
scrollY: hit.y,
|
|
627
|
+
scrollSource: 'wrapper'
|
|
628
|
+
} : {
|
|
629
|
+
scrollSource: 'none'
|
|
630
|
+
};
|
|
631
|
+
})(),
|
|
632
|
+
targetRect: (() => {
|
|
633
|
+
measureRoot();
|
|
634
|
+
const root = rootRectRef.current;
|
|
635
|
+
/* null -> undefined: the context field is optional, and an explicit null would
|
|
636
|
+
serialise as a JSON null claiming "measured, and it was nothing". */
|
|
637
|
+
return normalizeRect(readRectSync(target?.node), root ? {
|
|
638
|
+
w: root.w,
|
|
639
|
+
h: root.h
|
|
640
|
+
} : null) ?? undefined;
|
|
641
|
+
})(),
|
|
414
642
|
screenName,
|
|
643
|
+
screenSource,
|
|
415
644
|
ui,
|
|
416
645
|
stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
|
|
417
646
|
emitAutomaticAnalytics: captureAutomaticInteractions
|
|
@@ -456,7 +685,21 @@ export function ScaleBunDebugRoot({
|
|
|
456
685
|
// WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
|
|
457
686
|
// carries only coordinates, so every tap in the app groups into one row per gesture type.
|
|
458
687
|
// Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
|
|
459
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Resolved ONCE, so `target` and `targetId` cannot disagree about the same tap.
|
|
690
|
+
*
|
|
691
|
+
* The previous shape called `resolveTouchTarget(e)` inline for `target` while
|
|
692
|
+
* `targetId` came only from `start` — so on the fallback path (no JS start, or a
|
|
693
|
+
* refused correlation) the tap carried a description with NO id, and the two fields
|
|
694
|
+
* were computed from different sources. Anything joining on `targetId` therefore
|
|
695
|
+
* saw nothing for a tap that visibly had a target.
|
|
696
|
+
*/
|
|
697
|
+
const freshTarget = start ? null : resolveTouchTarget(e);
|
|
698
|
+
const tapped = start?.target ?? describeTouchTarget(freshTarget);
|
|
699
|
+
const identity = start ? {
|
|
700
|
+
id: start.targetId,
|
|
701
|
+
source: start.targetSource
|
|
702
|
+
} : targetIdentity(freshTarget);
|
|
460
703
|
const gestureDetails = {
|
|
461
704
|
target: tapped,
|
|
462
705
|
interactionId: start?.interactionId ?? generateInteractionId(),
|
|
@@ -464,8 +707,16 @@ export function ScaleBunDebugRoot({
|
|
|
464
707
|
occurredAt: start?.occurredAt ?? Date.now(),
|
|
465
708
|
ui: start?.ui,
|
|
466
709
|
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
467
|
-
targetId:
|
|
710
|
+
targetId: identity.id,
|
|
711
|
+
targetSource: identity.source,
|
|
468
712
|
screenName: start?.screenName,
|
|
713
|
+
screenSource: start?.screenSource ?? 'none',
|
|
714
|
+
targetRect: start?.targetRect,
|
|
715
|
+
scrollX: start?.scrollX,
|
|
716
|
+
scrollY: start?.scrollY,
|
|
717
|
+
scrollSource: start?.scrollSource ?? 'none',
|
|
718
|
+
itemKey: start?.itemKey,
|
|
719
|
+
itemIndex: start?.itemIndex,
|
|
469
720
|
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
470
721
|
x: start?.x ?? touch.pageX,
|
|
471
722
|
y: start?.y ?? touch.pageY,
|
|
@@ -488,6 +739,19 @@ export function ScaleBunDebugRoot({
|
|
|
488
739
|
const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
|
|
489
740
|
gestureDetails.normalizedX = nx;
|
|
490
741
|
gestureDetails.normalizedY = ny;
|
|
742
|
+
/**
|
|
743
|
+
* RN-15 on the JS-fallback lane.
|
|
744
|
+
*
|
|
745
|
+
* Computed here rather than in the literal above because that is where nx/ny first
|
|
746
|
+
* exist. The rect is the SAME one the native lane uses, so one physical tap yields
|
|
747
|
+
* the same local position whichever lane captured it — two lanes disagreeing about
|
|
748
|
+
* where in a control a tap landed would be worse than neither reporting it.
|
|
749
|
+
*/
|
|
750
|
+
const lp = localPoint(start?.targetRect ?? null, nx, ny);
|
|
751
|
+
if (lp) {
|
|
752
|
+
gestureDetails.localU = lp.u;
|
|
753
|
+
gestureDetails.localV = lp.v;
|
|
754
|
+
}
|
|
491
755
|
gestureDetails.normalizedPrecomputed = true;
|
|
492
756
|
gestureDetails.viewportWidth = rootRect.w;
|
|
493
757
|
gestureDetails.viewportHeight = rootRect.h;
|
|
@@ -671,6 +935,30 @@ export function useScaleBunScreen(name) {
|
|
|
671
935
|
}
|
|
672
936
|
} catch {/* no-throw */}
|
|
673
937
|
|
|
938
|
+
/**
|
|
939
|
+
* RN-8 — TELL THE DETECTOR TOO, because interactions read their screen from IT.
|
|
940
|
+
*
|
|
941
|
+
* This hook fed SessionManager, PerformanceFeature, ProfilerFeature and JourneyManager
|
|
942
|
+
* — four consumers — and never `AutoScreenDetector`. But `handleTouchStart` resolves an
|
|
943
|
+
* interaction's `screenName` from `AutoScreenDetector.getCurrentScreen()`, so a
|
|
944
|
+
* manually-instrumented app produced perfectly-named SCREEN_VIEW events while every TAP
|
|
945
|
+
* carried no screen at all. `setManualScreen` existed and was called only by tests.
|
|
946
|
+
*
|
|
947
|
+
* That is the whole story for apps the auto-hook cannot help: expo-router, a custom
|
|
948
|
+
* navigator, anything not `@react-navigation/native`. The documented manual escape hatch
|
|
949
|
+
* did not reach the one field that partitions every heatmap.
|
|
950
|
+
*
|
|
951
|
+
* Requires the last-writer-wins fix in `_currentScreen()` to be useful: under the
|
|
952
|
+
* previous rule a manual screen was readable for only 2000 ms, so wiring this alone
|
|
953
|
+
* would have handed interactions a name that expired before most taps happened.
|
|
954
|
+
*/
|
|
955
|
+
try {
|
|
956
|
+
const {
|
|
957
|
+
AutoScreenDetector
|
|
958
|
+
} = require('../navigation/AutoScreenDetector');
|
|
959
|
+
AutoScreenDetector.getInstance().setManualScreen(name);
|
|
960
|
+
} catch {/* no-throw */}
|
|
961
|
+
|
|
674
962
|
// Feed PerformanceFeature (screen context for perf metrics)
|
|
675
963
|
// NOTE: Only sets screen identity. Does NOT call markScreenLoadStart/End.
|
|
676
964
|
// AutoScreenLoadCollector handles timing independently via rAF + InteractionManager.
|
|
@@ -52,6 +52,34 @@ export const ScaleBunScrollView = /*#__PURE__*/React.forwardRef(function ScaleBu
|
|
|
52
52
|
}
|
|
53
53
|
} catch {/* no-throw */}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* RN-3 — record WHERE THIS scroller sits, keyed by its own native tag, so a later
|
|
57
|
+
* tap can say which content was under the finger.
|
|
58
|
+
*
|
|
59
|
+
* OUTSIDE the `isActive` guard above, deliberately. That guard is correct for the
|
|
60
|
+
* gesture tracker, which produces scroll EVENTS and should only do so while
|
|
61
|
+
* recording. This is a different thing: it is the position a tap needs in order to
|
|
62
|
+
* be placed in content rather than on glass, and a tap is captured on the always-on
|
|
63
|
+
* analytics lane even when no recording is active. Gating this the same way would
|
|
64
|
+
* make content depth absent in exactly the sessions the heatmap is built from.
|
|
65
|
+
*
|
|
66
|
+
* The tag is taken from the EVENT's target rather than from this component's ref,
|
|
67
|
+
* so it identifies the scroller that actually moved. With nested scrollers the
|
|
68
|
+
* event may come from an inner one, and keying by the wrapper would file the
|
|
69
|
+
* carousel's offset under the list.
|
|
70
|
+
*/
|
|
71
|
+
try {
|
|
72
|
+
const {
|
|
73
|
+
recordScrollOffset,
|
|
74
|
+
nativeTagOf
|
|
75
|
+
} = require('./scrollContext');
|
|
76
|
+
const {
|
|
77
|
+
contentOffset,
|
|
78
|
+
target
|
|
79
|
+
} = event.nativeEvent;
|
|
80
|
+
recordScrollOffset(typeof target === 'number' ? target : nativeTagOf(target), contentOffset.x, contentOffset.y);
|
|
81
|
+
} catch {/* no-throw */}
|
|
82
|
+
|
|
55
83
|
// Forward to user's onScroll handler
|
|
56
84
|
if (userOnScroll) {
|
|
57
85
|
userOnScroll(event);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { nativeTagOf, recordScrollOffset } from './scrollContext';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RN-3, ZERO-CONFIG — make every ScrollView report its offset, not just the wrapped ones.
|
|
5
|
+
*
|
|
6
|
+
* THE PROBLEM THIS SOLVES. Scroll offsets reach the SDK from `ScaleBunScrollView` and nowhere
|
|
7
|
+
* else, so an app using React Native's own `<ScrollView>` — which is most apps — produces taps
|
|
8
|
+
* with no content depth at all. Two taps at the same screen coordinate one viewport apart then
|
|
9
|
+
* merge into one hotspot, and the heatmap shows heavy engagement at a position where, in content
|
|
10
|
+
* terms, nothing in particular is. Asking every integration to swap its scroll views is a real
|
|
11
|
+
* migration cost and will never reach the long tail.
|
|
12
|
+
*
|
|
13
|
+
* WHY IT IS OFF BY DEFAULT, and why that is not timidity. This patches React Native's own module
|
|
14
|
+
* exports. Every scroll view in the host app — including ones inside third-party libraries the
|
|
15
|
+
* app did not write — starts routing through an SDK component. The failure mode of getting that
|
|
16
|
+
* wrong is not a wrong number in a dashboard, it is the host's UI behaving differently because an
|
|
17
|
+
* analytics SDK replaced a primitive. That is a decision an integrator makes with their eyes open,
|
|
18
|
+
* not a default they discover afterwards.
|
|
19
|
+
*
|
|
20
|
+
* THE PRECEDENT, AND ITS LESSON. The SDK already patches `@react-navigation/native` this way
|
|
21
|
+
* (`AutoScreenDetector._autoHookReactNavigation`). That hook shipped BROKEN: it assigned to module
|
|
22
|
+
* exports that are getters, which throws in strict mode, inside a `catch` that discarded the
|
|
23
|
+
* error. It reported success, captured no ref, and the whole screen-name pipeline stayed empty
|
|
24
|
+
* with nothing to indicate it. So this uses the same defence that fixed it — define the property
|
|
25
|
+
* rather than assign to it, then READ IT BACK — and refuses to claim success it has not verified.
|
|
26
|
+
*
|
|
27
|
+
* WHAT IT DELIBERATELY DOES NOT PATCH. `FlatList` and `SectionList` render a `ScrollView`
|
|
28
|
+
* internally, so patching that one export covers them without touching three. Fewer patch points
|
|
29
|
+
* is fewer ways to be wrong, and RN-4's row identity comes off the fiber regardless of which
|
|
30
|
+
* component rendered it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
let alreadyPatched = false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replace an export on a module whose exports are GETTERS.
|
|
37
|
+
*
|
|
38
|
+
* React Native's index is compiled re-exports, which bundlers emit as accessors with a getter and
|
|
39
|
+
* no setter. Plain assignment throws in strict mode and silently does nothing in sloppy mode.
|
|
40
|
+
* Defining the property works where assigning does not, and the read-back is what turns a future
|
|
41
|
+
* failure into a visible one instead of a silent no-op.
|
|
42
|
+
*/
|
|
43
|
+
function redefine(target, key, value) {
|
|
44
|
+
try {
|
|
45
|
+
Object.defineProperty(target, key, {
|
|
46
|
+
configurable: true,
|
|
47
|
+
enumerable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value
|
|
50
|
+
});
|
|
51
|
+
return target[key] === value;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrap a scroll component so its offsets are recorded, forwarding everything else untouched.
|
|
59
|
+
*
|
|
60
|
+
* The host's own `onScroll` is called AFTER the offset is recorded and is never replaced: an SDK
|
|
61
|
+
* that swallows a host's handler breaks the app, which is a far worse outcome than missing
|
|
62
|
+
* analytics. Recording first also means a throwing host handler cannot cost us the measurement.
|
|
63
|
+
*/
|
|
64
|
+
function instrument(Original, React) {
|
|
65
|
+
const Wrapped = React.forwardRef((props, ref) => {
|
|
66
|
+
const onScroll = event => {
|
|
67
|
+
try {
|
|
68
|
+
const target = event?.nativeEvent?.target;
|
|
69
|
+
const offset = event?.nativeEvent?.contentOffset;
|
|
70
|
+
if (offset) {
|
|
71
|
+
recordScrollOffset(typeof target === 'number' ? target : nativeTagOf(target), offset.x, offset.y);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* no-throw: a scroll offset is never worth breaking a gesture over */
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
props?.onScroll?.(event);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
/* The host's handler is the host's problem, but it must still run and still throw where
|
|
80
|
+
they can see it — rethrow on a later tick so this wrapper is not in the stack. */
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
throw e;
|
|
83
|
+
}, 0);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* `scrollEventThrottle` defaults to 16 only when the host has not chosen one. Overriding a
|
|
88
|
+
* host's explicit value would change how often their own handler runs — a behaviour change
|
|
89
|
+
* for a measurement, which is exactly the trade this module is not allowed to make.
|
|
90
|
+
*/
|
|
91
|
+
const throttle = props?.scrollEventThrottle ?? 16;
|
|
92
|
+
return React.createElement(Original, {
|
|
93
|
+
...props,
|
|
94
|
+
scrollEventThrottle: throttle,
|
|
95
|
+
onScroll,
|
|
96
|
+
ref
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
Wrapped.displayName = 'ScaleBunAutoScrollView';
|
|
100
|
+
return Wrapped;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Patch React Native's `ScrollView` export so every scroll view reports its offset.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
|
|
107
|
+
* than claiming a success it did not verify.
|
|
108
|
+
*/
|
|
109
|
+
export function autoInstrumentScrollViews() {
|
|
110
|
+
if (alreadyPatched) return {
|
|
111
|
+
installed: true
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
115
|
+
const RN = require('react-native');
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
117
|
+
const React = require('react');
|
|
118
|
+
const Original = RN.ScrollView;
|
|
119
|
+
if (typeof Original !== 'function' && typeof Original !== 'object') {
|
|
120
|
+
return {
|
|
121
|
+
installed: false,
|
|
122
|
+
reason: 'react-native has no ScrollView export to wrap'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (Original.__scalebunInstrumented) {
|
|
126
|
+
alreadyPatched = true;
|
|
127
|
+
return {
|
|
128
|
+
installed: true
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const Wrapped = instrument(Original, React);
|
|
132
|
+
Wrapped.__scalebunInstrumented = true;
|
|
133
|
+
if (!redefine(RN, 'ScrollView', Wrapped)) {
|
|
134
|
+
return {
|
|
135
|
+
installed: false,
|
|
136
|
+
reason: "react-native's ScrollView export could not be replaced — it is a getter with no " + 'configurable descriptor on this bundler. Use ScaleBunScrollView explicitly instead.'
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
alreadyPatched = true;
|
|
140
|
+
return {
|
|
141
|
+
installed: true
|
|
142
|
+
};
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
installed: false,
|
|
146
|
+
reason: e?.message ?? 'unknown failure'
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
|
|
152
|
+
export function resetAutoInstrumentForTests() {
|
|
153
|
+
alreadyPatched = false;
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=autoInstrumentScroll.js.map
|