@scalebun/react-native 1.13.0 → 2.0.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.
- package/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- 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/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -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 +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- 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/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- 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/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- 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/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- 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/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -52,6 +52,35 @@ export const ScaleBunScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
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 { recordScrollOffset, nativeTagOf } = require('./scrollContext');
|
|
73
|
+
const { contentOffset, target } = event.nativeEvent as unknown as {
|
|
74
|
+
contentOffset: { x: number; y: number };
|
|
75
|
+
target?: unknown;
|
|
76
|
+
};
|
|
77
|
+
recordScrollOffset(
|
|
78
|
+
typeof target === 'number' ? target : nativeTagOf(target),
|
|
79
|
+
contentOffset.x,
|
|
80
|
+
contentOffset.y,
|
|
81
|
+
);
|
|
82
|
+
} catch { /* no-throw */ }
|
|
83
|
+
|
|
55
84
|
// Forward to user's onScroll handler
|
|
56
85
|
if (userOnScroll) {
|
|
57
86
|
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
|
+
interface PatchResult {
|
|
34
|
+
/** True only when the replacement was verified by reading it back. */
|
|
35
|
+
installed: boolean;
|
|
36
|
+
/** Why not, when it did not install — for a diagnostic the host can act on. */
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let alreadyPatched = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replace an export on a module whose exports are GETTERS.
|
|
44
|
+
*
|
|
45
|
+
* React Native's index is compiled re-exports, which bundlers emit as accessors with a getter and
|
|
46
|
+
* no setter. Plain assignment throws in strict mode and silently does nothing in sloppy mode.
|
|
47
|
+
* Defining the property works where assigning does not, and the read-back is what turns a future
|
|
48
|
+
* failure into a visible one instead of a silent no-op.
|
|
49
|
+
*/
|
|
50
|
+
function redefine(target: Record<string, unknown>, key: string, value: unknown): boolean {
|
|
51
|
+
try {
|
|
52
|
+
Object.defineProperty(target, key, {
|
|
53
|
+
configurable: true,
|
|
54
|
+
enumerable: true,
|
|
55
|
+
writable: true,
|
|
56
|
+
value,
|
|
57
|
+
});
|
|
58
|
+
return target[key] === value;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wrap a scroll component so its offsets are recorded, forwarding everything else untouched.
|
|
66
|
+
*
|
|
67
|
+
* The host's own `onScroll` is called AFTER the offset is recorded and is never replaced: an SDK
|
|
68
|
+
* that swallows a host's handler breaks the app, which is a far worse outcome than missing
|
|
69
|
+
* analytics. Recording first also means a throwing host handler cannot cost us the measurement.
|
|
70
|
+
*/
|
|
71
|
+
function instrument(Original: any, React: any): any {
|
|
72
|
+
const Wrapped = React.forwardRef((props: any, ref: unknown) => {
|
|
73
|
+
const onScroll = (event: any) => {
|
|
74
|
+
try {
|
|
75
|
+
const target = event?.nativeEvent?.target;
|
|
76
|
+
const offset = event?.nativeEvent?.contentOffset;
|
|
77
|
+
if (offset) {
|
|
78
|
+
recordScrollOffset(
|
|
79
|
+
typeof target === 'number' ? target : nativeTagOf(target),
|
|
80
|
+
offset.x,
|
|
81
|
+
offset.y,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* no-throw: a scroll offset is never worth breaking a gesture over */
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
props?.onScroll?.(event);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
/* The host's handler is the host's problem, but it must still run and still throw where
|
|
91
|
+
they can see it — rethrow on a later tick so this wrapper is not in the stack. */
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
throw e;
|
|
94
|
+
}, 0);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* `scrollEventThrottle` defaults to 16 only when the host has not chosen one. Overriding a
|
|
99
|
+
* host's explicit value would change how often their own handler runs — a behaviour change
|
|
100
|
+
* for a measurement, which is exactly the trade this module is not allowed to make.
|
|
101
|
+
*/
|
|
102
|
+
const throttle = props?.scrollEventThrottle ?? 16;
|
|
103
|
+
return React.createElement(Original, {
|
|
104
|
+
...props,
|
|
105
|
+
scrollEventThrottle: throttle,
|
|
106
|
+
onScroll,
|
|
107
|
+
ref,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
Wrapped.displayName = 'ScaleBunAutoScrollView';
|
|
111
|
+
return Wrapped;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Patch React Native's `ScrollView` export so every scroll view reports its offset.
|
|
116
|
+
*
|
|
117
|
+
* Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
|
|
118
|
+
* than claiming a success it did not verify.
|
|
119
|
+
*/
|
|
120
|
+
export function autoInstrumentScrollViews(): PatchResult {
|
|
121
|
+
if (alreadyPatched) return { installed: true };
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
124
|
+
const RN = require('react-native') as Record<string, unknown>;
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
126
|
+
const React = require('react');
|
|
127
|
+
const Original = RN.ScrollView;
|
|
128
|
+
if (typeof Original !== 'function' && typeof Original !== 'object') {
|
|
129
|
+
return { installed: false, reason: 'react-native has no ScrollView export to wrap' };
|
|
130
|
+
}
|
|
131
|
+
if ((Original as { __scalebunInstrumented?: boolean }).__scalebunInstrumented) {
|
|
132
|
+
alreadyPatched = true;
|
|
133
|
+
return { installed: true };
|
|
134
|
+
}
|
|
135
|
+
const Wrapped = instrument(Original, React);
|
|
136
|
+
(Wrapped as { __scalebunInstrumented?: boolean }).__scalebunInstrumented = true;
|
|
137
|
+
if (!redefine(RN, 'ScrollView', Wrapped)) {
|
|
138
|
+
return {
|
|
139
|
+
installed: false,
|
|
140
|
+
reason:
|
|
141
|
+
"react-native's ScrollView export could not be replaced — it is a getter with no " +
|
|
142
|
+
'configurable descriptor on this bundler. Use ScaleBunScrollView explicitly instead.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
alreadyPatched = true;
|
|
146
|
+
return { installed: true };
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { installed: false, reason: (e as Error)?.message ?? 'unknown failure' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
|
|
153
|
+
export function resetAutoInstrumentForTests(): void {
|
|
154
|
+
alreadyPatched = false;
|
|
155
|
+
}
|
|
@@ -14,7 +14,52 @@ export interface InteractionStartContext {
|
|
|
14
14
|
y: number;
|
|
15
15
|
target?: string;
|
|
16
16
|
targetId?: string;
|
|
17
|
+
/**
|
|
18
|
+
* WHICH kind of identity `targetId` is: an authored `testID`, a structural component path, or
|
|
19
|
+
* a bare component name.
|
|
20
|
+
*
|
|
21
|
+
* Carried because they are not equally trustworthy — a testID is a promise from the app's
|
|
22
|
+
* authors, a path is an inference that breaks when the tree is refactored — and a consumer
|
|
23
|
+
* that cannot tell them apart renders a refactor-fragile guess as a confident target.
|
|
24
|
+
*/
|
|
25
|
+
targetSource?: 'testID' | 'path' | 'component' | 'none';
|
|
17
26
|
screenName?: string;
|
|
27
|
+
/** RN-8: where `screenName` came from, so low coverage is diagnosable rather than merely visible. */
|
|
28
|
+
screenSource?: 'manual' | 'navigation' | 'none';
|
|
29
|
+
/**
|
|
30
|
+
* RN-13: the target control's rectangle as fractions of the capture base, or undefined when it
|
|
31
|
+
* could not be measured synchronously. Comparable to the tap's own normalized point, which is
|
|
32
|
+
* what makes 'heat is on this control' checkable rather than assumed.
|
|
33
|
+
*/
|
|
34
|
+
targetRect?: { x: number; y: number; width: number; height: number };
|
|
35
|
+
/**
|
|
36
|
+
* RN-3: the content offset of the nearest ancestor scroller at finger-down, so two taps at the
|
|
37
|
+
* same screen coordinate one viewport apart do not merge. Absent when no ancestor reported one
|
|
38
|
+
* — never defaulted to 0, which is a real scroll position (the top of a list).
|
|
39
|
+
*/
|
|
40
|
+
scrollX?: number;
|
|
41
|
+
scrollY?: number;
|
|
42
|
+
/** Whether a scroll offset was available at all. 'none' means no wrapped scroller was an ancestor. */
|
|
43
|
+
scrollSource?: 'wrapper' | 'none';
|
|
44
|
+
/**
|
|
45
|
+
* RN-4: the list row's key, taken from the app's own `keyExtractor` via the cell's `cellKey`
|
|
46
|
+
* prop. Distinguishes two taps that share a RECYCLED native view — the same slot showing a
|
|
47
|
+
* different row after scrolling — which would otherwise merge into one hotspot.
|
|
48
|
+
*/
|
|
49
|
+
itemKey?: string;
|
|
50
|
+
/** The row's index at render time. Same index + different key = a recycled slot. */
|
|
51
|
+
itemIndex?: number;
|
|
52
|
+
/**
|
|
53
|
+
* RN-15: the tap's position INSIDE the target control — (0,0) its top-left, (1,1) its
|
|
54
|
+
* bottom-right. Resolution-independent, so one control is one distribution across every device
|
|
55
|
+
* size rather than a separate cloud of heat per screen width.
|
|
56
|
+
*
|
|
57
|
+
* NOT clamped. A value outside 0..1 means the tap landed outside the control's visual box and
|
|
58
|
+
* was still attributed to it, which is what `hitSlop` does — and a control whose taps cluster
|
|
59
|
+
* outside its own bounds is drawn too small, which is a finding rather than noise.
|
|
60
|
+
*/
|
|
61
|
+
localU?: number;
|
|
62
|
+
localV?: number;
|
|
18
63
|
ui?: string;
|
|
19
64
|
stateStatus: InteractionStateStatus;
|
|
20
65
|
emitAutomaticAnalytics: boolean;
|
|
@@ -24,22 +69,155 @@ export function generateInteractionId(): string {
|
|
|
24
69
|
return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
70
|
}
|
|
26
71
|
|
|
72
|
+
/**
|
|
73
|
+
* RN-6 — THE CORRELATION CONTRACT.
|
|
74
|
+
*
|
|
75
|
+
* WHAT IS BEING JOINED, AND WHY IT IS HARD. A physical tap is observed twice: natively, by
|
|
76
|
+
* a window callback that sees ACTION_DOWN/UP and produces the coordinates; and in JS, by a
|
|
77
|
+
* capture-phase touch handler that produces the IDENTITY — which control was hit, what UI
|
|
78
|
+
* state was declared, which screen. Both sides mint their own id and neither can derive
|
|
79
|
+
* the other's: native builds `ixn-a-<process nonce>-<MotionEvent ts>` from a clock JS
|
|
80
|
+
* cannot read, and JS builds `ixj-<Date.now()>-<random>`. So the two observations have to
|
|
81
|
+
* be JOINED, and the only shared quantity is time.
|
|
82
|
+
*
|
|
83
|
+
* WHY THE OLD JOIN WAS DANGEROUS RATHER THAN MERELY APPROXIMATE. It took whichever start
|
|
84
|
+
* was nearest in time within ±1500 ms and used it unconditionally. The fields it carries
|
|
85
|
+
* across are `target`, `targetId`, `ui`, `stateStatus` and `screenName`; the coordinates
|
|
86
|
+
* come from the native side and are always right. So a wrong pairing does not produce
|
|
87
|
+
* obviously broken data — it produces heat in the CORRECT position attributed to the
|
|
88
|
+
* WRONG control, in the wrong UI state, on the wrong screen. Nothing downstream can
|
|
89
|
+
* detect that, because every field is individually well-formed.
|
|
90
|
+
*
|
|
91
|
+
* 1500 ms is a very long time on a touch surface. A deliberate double-tap is 150–300 ms
|
|
92
|
+
* apart; an impatient tapper is faster. So "nearest within 1500 ms" routinely had more
|
|
93
|
+
* than one candidate, and picked one with no record that it had a choice.
|
|
94
|
+
*
|
|
95
|
+
* WHAT THIS REPLACES IT WITH — three outcomes instead of one, and a refusal:
|
|
96
|
+
*
|
|
97
|
+
* 'exact' one candidate is clearly closest: the runner-up is at least
|
|
98
|
+
* `marginMs` further away. The join is used.
|
|
99
|
+
* 'ambiguous' two candidates are within `marginMs` of each other, so choosing
|
|
100
|
+
* between them is a coin flip. The join is REFUSED and the caller must
|
|
101
|
+
* emit the interaction WITHOUT js-derived identity. A tap with no target
|
|
102
|
+
* is honest and visibly incomplete; a tap with the wrong target is
|
|
103
|
+
* indistinguishable from a correct one and corrupts every aggregate it
|
|
104
|
+
* enters.
|
|
105
|
+
* 'none' nothing within tolerance. Same refusal, different reason — worth
|
|
106
|
+
* separating because 'none' means the JS handler never fired (a real
|
|
107
|
+
* capture gap) while 'ambiguous' means it fired too often to attribute.
|
|
108
|
+
*
|
|
109
|
+
* It also reports `deltaMs` and `runnerUpDeltaMs` so the outcome is auditable after the
|
|
110
|
+
* fact rather than being a claim this comment makes.
|
|
111
|
+
*
|
|
112
|
+
* WHAT IT DELIBERATELY DOES NOT DO: use position to disambiguate. The native event carries
|
|
113
|
+
* raw px in the capture view and normalized 0..1 coordinates; the JS start carries its own
|
|
114
|
+
* x/y in a different space (dp in the root view). Comparing them needs a proven scale
|
|
115
|
+
* factor between the two, and getting it wrong would REJECT valid pairings — strictly
|
|
116
|
+
* worse than today. That calibration needs a device run, so it is a follow-up, not a
|
|
117
|
+
* guess made here.
|
|
118
|
+
*/
|
|
119
|
+
export type CorrelationOutcome = 'exact' | 'ambiguous' | 'none';
|
|
120
|
+
|
|
121
|
+
export interface CorrelationResult<T> {
|
|
122
|
+
/** Present only when `outcome` is 'exact'. Never a best-guess. */
|
|
123
|
+
start?: T;
|
|
124
|
+
/** Index into the input array, so the caller can consume it. -1 unless 'exact'. */
|
|
125
|
+
index: number;
|
|
126
|
+
outcome: CorrelationOutcome;
|
|
127
|
+
/** Distance to the chosen (or best rejected) candidate; null when there was none. */
|
|
128
|
+
deltaMs: number | null;
|
|
129
|
+
/** Distance to the second candidate — what makes 'ambiguous' auditable. */
|
|
130
|
+
runnerUpDeltaMs: number | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How far apart a native event and a JS start may be and still be the same touch.
|
|
135
|
+
*
|
|
136
|
+
* Unchanged at 1500 ms from the previous implementation, deliberately: narrowing it is a
|
|
137
|
+
* separate, measurable decision about how late the JS handler can run on a slow device,
|
|
138
|
+
* and bundling it into this change would make any regression impossible to attribute.
|
|
139
|
+
*/
|
|
140
|
+
export const CORRELATION_TOLERANCE_MS = 1500;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How much closer the winner must be than the runner-up to count as identified.
|
|
144
|
+
*
|
|
145
|
+
* 100 ms because that is comfortably below the fastest deliberate double-tap (~150 ms):
|
|
146
|
+
* when two candidate starts are within 100 ms of each other in distance, the native event
|
|
147
|
+
* sits effectively between them and the "nearest" one is a coin flip.
|
|
148
|
+
*/
|
|
149
|
+
export const CORRELATION_MARGIN_MS = 100;
|
|
150
|
+
|
|
27
151
|
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
28
|
-
export function
|
|
152
|
+
export function correlateInteraction<T extends InteractionStartContext>(
|
|
29
153
|
starts: readonly T[],
|
|
30
154
|
occurredAt: number,
|
|
31
|
-
toleranceMs =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
for (
|
|
155
|
+
toleranceMs = CORRELATION_TOLERANCE_MS,
|
|
156
|
+
marginMs = CORRELATION_MARGIN_MS,
|
|
157
|
+
): CorrelationResult<T> {
|
|
158
|
+
const candidates: Array<{ index: number; delta: number }> = [];
|
|
159
|
+
for (let i = 0; i < starts.length; i++) {
|
|
160
|
+
const start = starts[i];
|
|
161
|
+
if (!start) continue;
|
|
36
162
|
const delta = Math.abs(start.occurredAt - occurredAt);
|
|
37
|
-
if (delta
|
|
38
|
-
|
|
39
|
-
|
|
163
|
+
if (delta <= toleranceMs) candidates.push({ index: i, delta });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (candidates.length === 0) {
|
|
167
|
+
return { index: -1, outcome: 'none', deltaMs: null, runnerUpDeltaMs: null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Stable ordering: equal deltas resolve by index so a repeated run cannot produce a
|
|
171
|
+
different answer from the same inputs. A non-deterministic correlation would make
|
|
172
|
+
every downstream measurement unreproducible. */
|
|
173
|
+
candidates.sort((a, b) => (a.delta - b.delta) || (a.index - b.index));
|
|
174
|
+
const best = candidates[0]!;
|
|
175
|
+
const runnerUp = candidates[1];
|
|
176
|
+
|
|
177
|
+
if (runnerUp && runnerUp.delta - best.delta < marginMs) {
|
|
178
|
+
return {
|
|
179
|
+
index: -1,
|
|
180
|
+
outcome: 'ambiguous',
|
|
181
|
+
deltaMs: best.delta,
|
|
182
|
+
runnerUpDeltaMs: runnerUp.delta,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
start: starts[best.index],
|
|
188
|
+
index: best.index,
|
|
189
|
+
outcome: 'exact',
|
|
190
|
+
deltaMs: best.delta,
|
|
191
|
+
runnerUpDeltaMs: runnerUp ? runnerUp.delta : null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Drop starts that can no longer match anything, in place.
|
|
197
|
+
*
|
|
198
|
+
* WHY IT MATTERS BEYOND MEMORY. A start older than the tolerance is unreachable, but while
|
|
199
|
+
* it sits in the list it is still a CANDIDATE for the ambiguity test — so a stale entry can
|
|
200
|
+
* turn a perfectly clear pairing into a refusal, and an accumulating list makes that more
|
|
201
|
+
* likely the longer a session runs. Pruning is therefore part of the correctness of the
|
|
202
|
+
* join, not housekeeping.
|
|
203
|
+
*
|
|
204
|
+
* Mutates rather than returning a copy because the caller holds it in a ref that the native
|
|
205
|
+
* listener and the touch handler both read.
|
|
206
|
+
*/
|
|
207
|
+
export function pruneInteractionStarts<T extends InteractionStartContext>(
|
|
208
|
+
starts: T[],
|
|
209
|
+
now: number,
|
|
210
|
+
toleranceMs = CORRELATION_TOLERANCE_MS,
|
|
211
|
+
): number {
|
|
212
|
+
let removed = 0;
|
|
213
|
+
for (let i = starts.length - 1; i >= 0; i--) {
|
|
214
|
+
const start = starts[i];
|
|
215
|
+
if (!start || now - start.occurredAt > toleranceMs) {
|
|
216
|
+
starts.splice(i, 1);
|
|
217
|
+
removed++;
|
|
40
218
|
}
|
|
41
219
|
}
|
|
42
|
-
return
|
|
220
|
+
return removed;
|
|
43
221
|
}
|
|
44
222
|
|
|
45
223
|
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RN-3 — WHERE IN THE CONTENT A TAP HAPPENED, not just where on the glass.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT. A tap carries screen coordinates. Two taps at the SAME screen coordinate, one
|
|
5
|
+
* viewport apart in a scrolling list, are two different controls — and with no scroll offset they
|
|
6
|
+
* are indistinguishable, so they merge into one hotspot. The heatmap then shows heavy engagement
|
|
7
|
+
* at a position where, in content terms, nothing in particular is.
|
|
8
|
+
*
|
|
9
|
+
* That is not a rendering nicety. Scroll offset is a PLACEMENT dimension (which content was under
|
|
10
|
+
* the finger) and a CACHE dimension (two states of one route that must not share a backdrop), so
|
|
11
|
+
* its absence corrupts both the aggregate and the picture drawn beneath it.
|
|
12
|
+
*
|
|
13
|
+
* WHY A REGISTRY RATHER THAN A SINGLE NUMBER. `ScrollTracker` already accumulates
|
|
14
|
+
* `previousOffsetX/Y`, but as ONE global pair with no idea which scroller moved. A screen with a
|
|
15
|
+
* vertical list containing a horizontal carousel has two scrollers, and whichever fired last
|
|
16
|
+
* overwrites the other — so the vertical position of a tap inside the carousel is whatever the
|
|
17
|
+
* carousel's horizontal offset happened to leave behind. A single global scrollY cannot express a
|
|
18
|
+
* nested scroller, and silently reports a wrong number rather than none.
|
|
19
|
+
*
|
|
20
|
+
* So offsets are keyed by the scroller's NATIVE TAG, which is the one identity both ends of this
|
|
21
|
+
* can see: the wrapper holds a ref to its own ScrollView, and the touch handler's fiber walk
|
|
22
|
+
* passes the same host instances on its way up. Matching on anything else — a name, an index, a
|
|
23
|
+
* generated id — would require the two sides to agree on a convention, and a convention that can
|
|
24
|
+
* drift is a join that can silently attach the wrong scroller's offset.
|
|
25
|
+
*
|
|
26
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: guess. If no ancestor of the tapped node has a recorded
|
|
27
|
+
* offset, the answer is null, not zero. Zero is a real scroll position — the top of a list — and
|
|
28
|
+
* reporting it for "we do not know" would place every unmeasured tap at the top of its content,
|
|
29
|
+
* which is both wrong and indistinguishable from a genuine tap there.
|
|
30
|
+
*
|
|
31
|
+
* KNOWN LIMIT, stated because it bounds what this can deliver: offsets only arrive from
|
|
32
|
+
* `ScaleBunScrollView`, which an app has to opt into. A plain `<ScrollView>` reports nothing, so
|
|
33
|
+
* `scrollSource` is `'none'` and the tap has no content depth. Making it automatic means patching
|
|
34
|
+
* React Native's own exports — the SDK already does that for `@react-navigation/native` — and that
|
|
35
|
+
* is a decision with a much larger blast radius, not one to make silently inside a data fix.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ScrollOffset {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
/** When it was recorded; used only to expire entries, never reported. */
|
|
42
|
+
at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a recorded offset stays usable.
|
|
47
|
+
*
|
|
48
|
+
* A scroller that has not moved in this long may well have been unmounted — a list on a screen the
|
|
49
|
+
* user left — and its stale offset must not be attached to a tap on a DIFFERENT screen that happens
|
|
50
|
+
* to reuse the same native tag. Tags are recycled by the platform, which makes this a correctness
|
|
51
|
+
* bound rather than a memory one.
|
|
52
|
+
*
|
|
53
|
+
* Five minutes is far longer than any plausible gap between scrolling a list and tapping in it,
|
|
54
|
+
* and far shorter than a session.
|
|
55
|
+
*/
|
|
56
|
+
export const SCROLL_OFFSET_TTL_MS = 5 * 60_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cap on tracked scrollers.
|
|
60
|
+
*
|
|
61
|
+
* Bounded because a long session on a virtualised screen can mount and unmount many scrollers, and
|
|
62
|
+
* an unbounded map is a leak that only shows up in the sessions that matter most — the long ones.
|
|
63
|
+
* Eviction is oldest-first, which is the entry least likely to be the one a tap needs.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_TRACKED_SCROLLERS = 32;
|
|
66
|
+
|
|
67
|
+
const offsets = new Map<number, ScrollOffset>();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The native tag of a host instance, across the shapes React Native has used.
|
|
71
|
+
*
|
|
72
|
+
* Fabric exposes `__nativeTag`; the old architecture used `_nativeTag`. Probing both means this
|
|
73
|
+
* keeps working on either architecture, and returns null rather than throwing on anything else —
|
|
74
|
+
* a scroll offset is never worth a crash.
|
|
75
|
+
*/
|
|
76
|
+
export function nativeTagOf(node: unknown): number | null {
|
|
77
|
+
if (!node || typeof node !== 'object') return null;
|
|
78
|
+
const n = node as { __nativeTag?: unknown; _nativeTag?: unknown };
|
|
79
|
+
const tag = typeof n.__nativeTag === 'number' ? n.__nativeTag : n._nativeTag;
|
|
80
|
+
return typeof tag === 'number' && Number.isFinite(tag) ? tag : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Record where a scroller currently sits. Called on every scroll event of a wrapped scroller. */
|
|
84
|
+
export function recordScrollOffset(tag: number | null, x: number, y: number, now = Date.now()): void {
|
|
85
|
+
if (tag === null || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
86
|
+
offsets.set(tag, { x, y, at: now });
|
|
87
|
+
if (offsets.size > MAX_TRACKED_SCROLLERS) {
|
|
88
|
+
let oldestTag: number | null = null;
|
|
89
|
+
let oldestAt = Infinity;
|
|
90
|
+
for (const [k, v] of offsets) {
|
|
91
|
+
if (v.at < oldestAt) {
|
|
92
|
+
oldestAt = v.at;
|
|
93
|
+
oldestTag = k;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (oldestTag !== null) offsets.delete(oldestTag);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The offset of the NEAREST ancestor scroller that has one, given the tapped node's ancestor tags
|
|
102
|
+
* ordered innermost first.
|
|
103
|
+
*
|
|
104
|
+
* Nearest wins because that is the scroller the content under the finger actually moved with. A
|
|
105
|
+
* tap inside a horizontal carousel inside a vertical list belongs to the carousel's content
|
|
106
|
+
* position; attributing it to the outer list would place it by how far the PAGE had scrolled,
|
|
107
|
+
* which is the merge this whole module exists to prevent.
|
|
108
|
+
*
|
|
109
|
+
* Returns null when no ancestor has a recorded offset — never a zero.
|
|
110
|
+
*/
|
|
111
|
+
export function nearestRecordedOffset(
|
|
112
|
+
ancestorTags: readonly (number | null)[],
|
|
113
|
+
now = Date.now(),
|
|
114
|
+
): { x: number; y: number; tag: number } | null {
|
|
115
|
+
for (const tag of ancestorTags) {
|
|
116
|
+
if (tag === null) continue;
|
|
117
|
+
const hit = offsets.get(tag);
|
|
118
|
+
if (!hit) continue;
|
|
119
|
+
if (now - hit.at > SCROLL_OFFSET_TTL_MS) {
|
|
120
|
+
/* Expired: drop it so a recycled tag cannot serve a previous screen's offset. */
|
|
121
|
+
offsets.delete(tag);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
return { x: hit.x, y: hit.y, tag };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Forget everything. Called between sessions so one recording cannot inherit another's scrollers. */
|
|
130
|
+
export function clearScrollOffsets(): void {
|
|
131
|
+
offsets.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How many scrollers are currently tracked — for the leak assertion in tests. */
|
|
135
|
+
export function trackedScrollerCount(): number {
|
|
136
|
+
return offsets.size;
|
|
137
|
+
}
|