@scalebun/react-native 2.0.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.
Files changed (70) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +18 -3
  4. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -1
  5. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  6. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +2 -0
  7. package/dist/scalebun.full.js +840 -114
  8. package/dist/scalebun.slim.js +838 -112
  9. package/ios/ReplaySdk.swift +65 -2
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  11. package/lib/commonjs/core/config/schema.js +16 -0
  12. package/lib/commonjs/core/constants/version.js +1 -1
  13. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  14. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  15. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  16. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  17. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  18. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  19. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  20. package/lib/commonjs/features/journey/uiState.js +81 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +69 -22
  23. package/lib/commonjs/features/session/SessionManager.js +63 -0
  24. package/lib/commonjs/public/ScaleBunFacade.js +18 -16
  25. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  26. package/lib/module/core/config/schema.js +16 -0
  27. package/lib/module/core/constants/version.js +1 -1
  28. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  29. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  30. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  31. package/lib/module/features/journey/interactionProtocol.js +128 -8
  32. package/lib/module/features/journey/scrollContext.js +135 -0
  33. package/lib/module/features/journey/targetGeometry.js +155 -0
  34. package/lib/module/features/journey/touchTarget.js +151 -24
  35. package/lib/module/features/journey/uiState.js +78 -1
  36. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  37. package/lib/module/features/ota/OtaOrchestrator.js +69 -22
  38. package/lib/module/features/session/SessionManager.js +63 -0
  39. package/lib/module/public/ScaleBunFacade.js +18 -16
  40. package/lib/typescript/core/config/schema.d.ts +2 -0
  41. package/lib/typescript/core/constants/version.d.ts +1 -1
  42. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  43. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  44. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  45. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  46. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  47. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  48. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  49. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +7 -0
  50. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  51. package/lib/typescript/public/types.d.ts +10 -0
  52. package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
  53. package/package.json +2 -2
  54. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  55. package/src/core/config/schema.ts +18 -0
  56. package/src/core/constants/version.ts +1 -1
  57. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  58. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  59. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  60. package/src/features/journey/interactionProtocol.ts +188 -10
  61. package/src/features/journey/scrollContext.ts +137 -0
  62. package/src/features/journey/targetGeometry.ts +176 -0
  63. package/src/features/journey/touchTarget.ts +237 -26
  64. package/src/features/journey/uiState.ts +80 -1
  65. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  66. package/src/features/ota/OtaOrchestrator.ts +76 -22
  67. package/src/features/session/SessionManager.ts +113 -0
  68. package/src/public/ScaleBunFacade.ts +18 -16
  69. package/src/public/types.ts +10 -0
  70. package/src/specs/NativeScaleBunOta.ts +3 -0
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.autoInstrumentScrollViews = autoInstrumentScrollViews;
7
+ exports.resetAutoInstrumentForTests = resetAutoInstrumentForTests;
8
+ var _scrollContext = require("./scrollContext");
9
+ /**
10
+ * RN-3, ZERO-CONFIG — make every ScrollView report its offset, not just the wrapped ones.
11
+ *
12
+ * THE PROBLEM THIS SOLVES. Scroll offsets reach the SDK from `ScaleBunScrollView` and nowhere
13
+ * else, so an app using React Native's own `<ScrollView>` — which is most apps — produces taps
14
+ * with no content depth at all. Two taps at the same screen coordinate one viewport apart then
15
+ * merge into one hotspot, and the heatmap shows heavy engagement at a position where, in content
16
+ * terms, nothing in particular is. Asking every integration to swap its scroll views is a real
17
+ * migration cost and will never reach the long tail.
18
+ *
19
+ * WHY IT IS OFF BY DEFAULT, and why that is not timidity. This patches React Native's own module
20
+ * exports. Every scroll view in the host app — including ones inside third-party libraries the
21
+ * app did not write — starts routing through an SDK component. The failure mode of getting that
22
+ * wrong is not a wrong number in a dashboard, it is the host's UI behaving differently because an
23
+ * analytics SDK replaced a primitive. That is a decision an integrator makes with their eyes open,
24
+ * not a default they discover afterwards.
25
+ *
26
+ * THE PRECEDENT, AND ITS LESSON. The SDK already patches `@react-navigation/native` this way
27
+ * (`AutoScreenDetector._autoHookReactNavigation`). That hook shipped BROKEN: it assigned to module
28
+ * exports that are getters, which throws in strict mode, inside a `catch` that discarded the
29
+ * error. It reported success, captured no ref, and the whole screen-name pipeline stayed empty
30
+ * with nothing to indicate it. So this uses the same defence that fixed it — define the property
31
+ * rather than assign to it, then READ IT BACK — and refuses to claim success it has not verified.
32
+ *
33
+ * WHAT IT DELIBERATELY DOES NOT PATCH. `FlatList` and `SectionList` render a `ScrollView`
34
+ * internally, so patching that one export covers them without touching three. Fewer patch points
35
+ * is fewer ways to be wrong, and RN-4's row identity comes off the fiber regardless of which
36
+ * component rendered it.
37
+ */
38
+
39
+ let alreadyPatched = false;
40
+
41
+ /**
42
+ * Replace an export on a module whose exports are GETTERS.
43
+ *
44
+ * React Native's index is compiled re-exports, which bundlers emit as accessors with a getter and
45
+ * no setter. Plain assignment throws in strict mode and silently does nothing in sloppy mode.
46
+ * Defining the property works where assigning does not, and the read-back is what turns a future
47
+ * failure into a visible one instead of a silent no-op.
48
+ */
49
+ function redefine(target, key, value) {
50
+ try {
51
+ Object.defineProperty(target, key, {
52
+ configurable: true,
53
+ enumerable: true,
54
+ writable: true,
55
+ value
56
+ });
57
+ return target[key] === value;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Wrap a scroll component so its offsets are recorded, forwarding everything else untouched.
65
+ *
66
+ * The host's own `onScroll` is called AFTER the offset is recorded and is never replaced: an SDK
67
+ * that swallows a host's handler breaks the app, which is a far worse outcome than missing
68
+ * analytics. Recording first also means a throwing host handler cannot cost us the measurement.
69
+ */
70
+ function instrument(Original, React) {
71
+ const Wrapped = React.forwardRef((props, ref) => {
72
+ const onScroll = event => {
73
+ try {
74
+ const target = event?.nativeEvent?.target;
75
+ const offset = event?.nativeEvent?.contentOffset;
76
+ if (offset) {
77
+ (0, _scrollContext.recordScrollOffset)(typeof target === 'number' ? target : (0, _scrollContext.nativeTagOf)(target), offset.x, offset.y);
78
+ }
79
+ } catch {
80
+ /* no-throw: a scroll offset is never worth breaking a gesture over */
81
+ }
82
+ try {
83
+ props?.onScroll?.(event);
84
+ } catch (e) {
85
+ /* The host's handler is the host's problem, but it must still run and still throw where
86
+ they can see it — rethrow on a later tick so this wrapper is not in the stack. */
87
+ setTimeout(() => {
88
+ throw e;
89
+ }, 0);
90
+ }
91
+ };
92
+ /**
93
+ * `scrollEventThrottle` defaults to 16 only when the host has not chosen one. Overriding a
94
+ * host's explicit value would change how often their own handler runs — a behaviour change
95
+ * for a measurement, which is exactly the trade this module is not allowed to make.
96
+ */
97
+ const throttle = props?.scrollEventThrottle ?? 16;
98
+ return React.createElement(Original, {
99
+ ...props,
100
+ scrollEventThrottle: throttle,
101
+ onScroll,
102
+ ref
103
+ });
104
+ });
105
+ Wrapped.displayName = 'ScaleBunAutoScrollView';
106
+ return Wrapped;
107
+ }
108
+
109
+ /**
110
+ * Patch React Native's `ScrollView` export so every scroll view reports its offset.
111
+ *
112
+ * Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
113
+ * than claiming a success it did not verify.
114
+ */
115
+ function autoInstrumentScrollViews() {
116
+ if (alreadyPatched) return {
117
+ installed: true
118
+ };
119
+ try {
120
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
121
+ const RN = require('react-native');
122
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
123
+ const React = require('react');
124
+ const Original = RN.ScrollView;
125
+ if (typeof Original !== 'function' && typeof Original !== 'object') {
126
+ return {
127
+ installed: false,
128
+ reason: 'react-native has no ScrollView export to wrap'
129
+ };
130
+ }
131
+ if (Original.__scalebunInstrumented) {
132
+ alreadyPatched = true;
133
+ return {
134
+ installed: true
135
+ };
136
+ }
137
+ const Wrapped = instrument(Original, React);
138
+ Wrapped.__scalebunInstrumented = true;
139
+ if (!redefine(RN, 'ScrollView', Wrapped)) {
140
+ return {
141
+ installed: false,
142
+ 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.'
143
+ };
144
+ }
145
+ alreadyPatched = true;
146
+ return {
147
+ installed: true
148
+ };
149
+ } catch (e) {
150
+ return {
151
+ installed: false,
152
+ reason: e?.message ?? 'unknown failure'
153
+ };
154
+ }
155
+ }
156
+
157
+ /** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
158
+ function resetAutoInstrumentForTests() {
159
+ alreadyPatched = false;
160
+ }
161
+ //# sourceMappingURL=autoInstrumentScroll.js.map
@@ -3,28 +3,149 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.INTERACTION_PROTOCOL_VERSION = void 0;
6
+ exports.INTERACTION_PROTOCOL_VERSION = exports.CORRELATION_TOLERANCE_MS = exports.CORRELATION_MARGIN_MS = void 0;
7
7
  exports.automaticInteractionProperties = automaticInteractionProperties;
8
+ exports.correlateInteraction = correlateInteraction;
8
9
  exports.generateInteractionId = generateInteractionId;
9
- exports.nearestInteractionStart = nearestInteractionStart;
10
+ exports.pruneInteractionStarts = pruneInteractionStarts;
10
11
  /** One physical interaction, shared by replay and analytics projections. */
11
12
  const INTERACTION_PROTOCOL_VERSION = exports.INTERACTION_PROTOCOL_VERSION = 1;
12
13
  function generateInteractionId() {
13
14
  return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
14
15
  }
15
16
 
17
+ /**
18
+ * RN-6 — THE CORRELATION CONTRACT.
19
+ *
20
+ * WHAT IS BEING JOINED, AND WHY IT IS HARD. A physical tap is observed twice: natively, by
21
+ * a window callback that sees ACTION_DOWN/UP and produces the coordinates; and in JS, by a
22
+ * capture-phase touch handler that produces the IDENTITY — which control was hit, what UI
23
+ * state was declared, which screen. Both sides mint their own id and neither can derive
24
+ * the other's: native builds `ixn-a-<process nonce>-<MotionEvent ts>` from a clock JS
25
+ * cannot read, and JS builds `ixj-<Date.now()>-<random>`. So the two observations have to
26
+ * be JOINED, and the only shared quantity is time.
27
+ *
28
+ * WHY THE OLD JOIN WAS DANGEROUS RATHER THAN MERELY APPROXIMATE. It took whichever start
29
+ * was nearest in time within ±1500 ms and used it unconditionally. The fields it carries
30
+ * across are `target`, `targetId`, `ui`, `stateStatus` and `screenName`; the coordinates
31
+ * come from the native side and are always right. So a wrong pairing does not produce
32
+ * obviously broken data — it produces heat in the CORRECT position attributed to the
33
+ * WRONG control, in the wrong UI state, on the wrong screen. Nothing downstream can
34
+ * detect that, because every field is individually well-formed.
35
+ *
36
+ * 1500 ms is a very long time on a touch surface. A deliberate double-tap is 150–300 ms
37
+ * apart; an impatient tapper is faster. So "nearest within 1500 ms" routinely had more
38
+ * than one candidate, and picked one with no record that it had a choice.
39
+ *
40
+ * WHAT THIS REPLACES IT WITH — three outcomes instead of one, and a refusal:
41
+ *
42
+ * 'exact' one candidate is clearly closest: the runner-up is at least
43
+ * `marginMs` further away. The join is used.
44
+ * 'ambiguous' two candidates are within `marginMs` of each other, so choosing
45
+ * between them is a coin flip. The join is REFUSED and the caller must
46
+ * emit the interaction WITHOUT js-derived identity. A tap with no target
47
+ * is honest and visibly incomplete; a tap with the wrong target is
48
+ * indistinguishable from a correct one and corrupts every aggregate it
49
+ * enters.
50
+ * 'none' nothing within tolerance. Same refusal, different reason — worth
51
+ * separating because 'none' means the JS handler never fired (a real
52
+ * capture gap) while 'ambiguous' means it fired too often to attribute.
53
+ *
54
+ * It also reports `deltaMs` and `runnerUpDeltaMs` so the outcome is auditable after the
55
+ * fact rather than being a claim this comment makes.
56
+ *
57
+ * WHAT IT DELIBERATELY DOES NOT DO: use position to disambiguate. The native event carries
58
+ * raw px in the capture view and normalized 0..1 coordinates; the JS start carries its own
59
+ * x/y in a different space (dp in the root view). Comparing them needs a proven scale
60
+ * factor between the two, and getting it wrong would REJECT valid pairings — strictly
61
+ * worse than today. That calibration needs a device run, so it is a follow-up, not a
62
+ * guess made here.
63
+ */
64
+
65
+ /**
66
+ * How far apart a native event and a JS start may be and still be the same touch.
67
+ *
68
+ * Unchanged at 1500 ms from the previous implementation, deliberately: narrowing it is a
69
+ * separate, measurable decision about how late the JS handler can run on a slow device,
70
+ * and bundling it into this change would make any regression impossible to attribute.
71
+ */
72
+ const CORRELATION_TOLERANCE_MS = exports.CORRELATION_TOLERANCE_MS = 1500;
73
+
74
+ /**
75
+ * How much closer the winner must be than the runner-up to count as identified.
76
+ *
77
+ * 100 ms because that is comfortably below the fastest deliberate double-tap (~150 ms):
78
+ * when two candidate starts are within 100 ms of each other in distance, the native event
79
+ * sits effectively between them and the "nearest" one is a coin flip.
80
+ */
81
+ const CORRELATION_MARGIN_MS = exports.CORRELATION_MARGIN_MS = 100;
82
+
16
83
  /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
17
- function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
18
- let best;
19
- let bestDelta = toleranceMs + 1;
20
- for (const start of starts) {
84
+ function correlateInteraction(starts, occurredAt, toleranceMs = CORRELATION_TOLERANCE_MS, marginMs = CORRELATION_MARGIN_MS) {
85
+ const candidates = [];
86
+ for (let i = 0; i < starts.length; i++) {
87
+ const start = starts[i];
88
+ if (!start) continue;
21
89
  const delta = Math.abs(start.occurredAt - occurredAt);
22
- if (delta < bestDelta) {
23
- best = start;
24
- bestDelta = delta;
90
+ if (delta <= toleranceMs) candidates.push({
91
+ index: i,
92
+ delta
93
+ });
94
+ }
95
+ if (candidates.length === 0) {
96
+ return {
97
+ index: -1,
98
+ outcome: 'none',
99
+ deltaMs: null,
100
+ runnerUpDeltaMs: null
101
+ };
102
+ }
103
+
104
+ /* Stable ordering: equal deltas resolve by index so a repeated run cannot produce a
105
+ different answer from the same inputs. A non-deterministic correlation would make
106
+ every downstream measurement unreproducible. */
107
+ candidates.sort((a, b) => a.delta - b.delta || a.index - b.index);
108
+ const best = candidates[0];
109
+ const runnerUp = candidates[1];
110
+ if (runnerUp && runnerUp.delta - best.delta < marginMs) {
111
+ return {
112
+ index: -1,
113
+ outcome: 'ambiguous',
114
+ deltaMs: best.delta,
115
+ runnerUpDeltaMs: runnerUp.delta
116
+ };
117
+ }
118
+ return {
119
+ start: starts[best.index],
120
+ index: best.index,
121
+ outcome: 'exact',
122
+ deltaMs: best.delta,
123
+ runnerUpDeltaMs: runnerUp ? runnerUp.delta : null
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Drop starts that can no longer match anything, in place.
129
+ *
130
+ * WHY IT MATTERS BEYOND MEMORY. A start older than the tolerance is unreachable, but while
131
+ * it sits in the list it is still a CANDIDATE for the ambiguity test — so a stale entry can
132
+ * turn a perfectly clear pairing into a refusal, and an accumulating list makes that more
133
+ * likely the longer a session runs. Pruning is therefore part of the correctness of the
134
+ * join, not housekeeping.
135
+ *
136
+ * Mutates rather than returning a copy because the caller holds it in a ref that the native
137
+ * listener and the touch handler both read.
138
+ */
139
+ function pruneInteractionStarts(starts, now, toleranceMs = CORRELATION_TOLERANCE_MS) {
140
+ let removed = 0;
141
+ for (let i = starts.length - 1; i >= 0; i--) {
142
+ const start = starts[i];
143
+ if (!start || now - start.occurredAt > toleranceMs) {
144
+ starts.splice(i, 1);
145
+ removed++;
25
146
  }
26
147
  }
27
- return bestDelta <= toleranceMs ? best : undefined;
148
+ return removed;
28
149
  }
29
150
 
30
151
  /** Analytics is a projection of the same evidence; no second click is invented. */
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.SCROLL_OFFSET_TTL_MS = void 0;
7
+ exports.clearScrollOffsets = clearScrollOffsets;
8
+ exports.nativeTagOf = nativeTagOf;
9
+ exports.nearestRecordedOffset = nearestRecordedOffset;
10
+ exports.recordScrollOffset = recordScrollOffset;
11
+ exports.trackedScrollerCount = trackedScrollerCount;
12
+ /**
13
+ * RN-3 — WHERE IN THE CONTENT A TAP HAPPENED, not just where on the glass.
14
+ *
15
+ * THE DEFECT. A tap carries screen coordinates. Two taps at the SAME screen coordinate, one
16
+ * viewport apart in a scrolling list, are two different controls — and with no scroll offset they
17
+ * are indistinguishable, so they merge into one hotspot. The heatmap then shows heavy engagement
18
+ * at a position where, in content terms, nothing in particular is.
19
+ *
20
+ * That is not a rendering nicety. Scroll offset is a PLACEMENT dimension (which content was under
21
+ * the finger) and a CACHE dimension (two states of one route that must not share a backdrop), so
22
+ * its absence corrupts both the aggregate and the picture drawn beneath it.
23
+ *
24
+ * WHY A REGISTRY RATHER THAN A SINGLE NUMBER. `ScrollTracker` already accumulates
25
+ * `previousOffsetX/Y`, but as ONE global pair with no idea which scroller moved. A screen with a
26
+ * vertical list containing a horizontal carousel has two scrollers, and whichever fired last
27
+ * overwrites the other — so the vertical position of a tap inside the carousel is whatever the
28
+ * carousel's horizontal offset happened to leave behind. A single global scrollY cannot express a
29
+ * nested scroller, and silently reports a wrong number rather than none.
30
+ *
31
+ * So offsets are keyed by the scroller's NATIVE TAG, which is the one identity both ends of this
32
+ * can see: the wrapper holds a ref to its own ScrollView, and the touch handler's fiber walk
33
+ * passes the same host instances on its way up. Matching on anything else — a name, an index, a
34
+ * generated id — would require the two sides to agree on a convention, and a convention that can
35
+ * drift is a join that can silently attach the wrong scroller's offset.
36
+ *
37
+ * WHAT THIS DELIBERATELY DOES NOT DO: guess. If no ancestor of the tapped node has a recorded
38
+ * offset, the answer is null, not zero. Zero is a real scroll position — the top of a list — and
39
+ * reporting it for "we do not know" would place every unmeasured tap at the top of its content,
40
+ * which is both wrong and indistinguishable from a genuine tap there.
41
+ *
42
+ * KNOWN LIMIT, stated because it bounds what this can deliver: offsets only arrive from
43
+ * `ScaleBunScrollView`, which an app has to opt into. A plain `<ScrollView>` reports nothing, so
44
+ * `scrollSource` is `'none'` and the tap has no content depth. Making it automatic means patching
45
+ * React Native's own exports — the SDK already does that for `@react-navigation/native` — and that
46
+ * is a decision with a much larger blast radius, not one to make silently inside a data fix.
47
+ */
48
+
49
+ /**
50
+ * How long a recorded offset stays usable.
51
+ *
52
+ * A scroller that has not moved in this long may well have been unmounted — a list on a screen the
53
+ * user left — and its stale offset must not be attached to a tap on a DIFFERENT screen that happens
54
+ * to reuse the same native tag. Tags are recycled by the platform, which makes this a correctness
55
+ * bound rather than a memory one.
56
+ *
57
+ * Five minutes is far longer than any plausible gap between scrolling a list and tapping in it,
58
+ * and far shorter than a session.
59
+ */
60
+ const SCROLL_OFFSET_TTL_MS = exports.SCROLL_OFFSET_TTL_MS = 5 * 60_000;
61
+
62
+ /**
63
+ * Cap on tracked scrollers.
64
+ *
65
+ * Bounded because a long session on a virtualised screen can mount and unmount many scrollers, and
66
+ * an unbounded map is a leak that only shows up in the sessions that matter most — the long ones.
67
+ * Eviction is oldest-first, which is the entry least likely to be the one a tap needs.
68
+ */
69
+ const MAX_TRACKED_SCROLLERS = 32;
70
+ const offsets = new Map();
71
+
72
+ /**
73
+ * The native tag of a host instance, across the shapes React Native has used.
74
+ *
75
+ * Fabric exposes `__nativeTag`; the old architecture used `_nativeTag`. Probing both means this
76
+ * keeps working on either architecture, and returns null rather than throwing on anything else —
77
+ * a scroll offset is never worth a crash.
78
+ */
79
+ function nativeTagOf(node) {
80
+ if (!node || typeof node !== 'object') return null;
81
+ const n = node;
82
+ const tag = typeof n.__nativeTag === 'number' ? n.__nativeTag : n._nativeTag;
83
+ return typeof tag === 'number' && Number.isFinite(tag) ? tag : null;
84
+ }
85
+
86
+ /** Record where a scroller currently sits. Called on every scroll event of a wrapped scroller. */
87
+ function recordScrollOffset(tag, x, y, now = Date.now()) {
88
+ if (tag === null || !Number.isFinite(x) || !Number.isFinite(y)) return;
89
+ offsets.set(tag, {
90
+ x,
91
+ y,
92
+ at: now
93
+ });
94
+ if (offsets.size > MAX_TRACKED_SCROLLERS) {
95
+ let oldestTag = null;
96
+ let oldestAt = Infinity;
97
+ for (const [k, v] of offsets) {
98
+ if (v.at < oldestAt) {
99
+ oldestAt = v.at;
100
+ oldestTag = k;
101
+ }
102
+ }
103
+ if (oldestTag !== null) offsets.delete(oldestTag);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The offset of the NEAREST ancestor scroller that has one, given the tapped node's ancestor tags
109
+ * ordered innermost first.
110
+ *
111
+ * Nearest wins because that is the scroller the content under the finger actually moved with. A
112
+ * tap inside a horizontal carousel inside a vertical list belongs to the carousel's content
113
+ * position; attributing it to the outer list would place it by how far the PAGE had scrolled,
114
+ * which is the merge this whole module exists to prevent.
115
+ *
116
+ * Returns null when no ancestor has a recorded offset — never a zero.
117
+ */
118
+ function nearestRecordedOffset(ancestorTags, now = Date.now()) {
119
+ for (const tag of ancestorTags) {
120
+ if (tag === null) continue;
121
+ const hit = offsets.get(tag);
122
+ if (!hit) continue;
123
+ if (now - hit.at > SCROLL_OFFSET_TTL_MS) {
124
+ /* Expired: drop it so a recycled tag cannot serve a previous screen's offset. */
125
+ offsets.delete(tag);
126
+ continue;
127
+ }
128
+ return {
129
+ x: hit.x,
130
+ y: hit.y,
131
+ tag
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** Forget everything. Called between sessions so one recording cannot inherit another's scrollers. */
138
+ function clearScrollOffsets() {
139
+ offsets.clear();
140
+ }
141
+
142
+ /** How many scrollers are currently tracked — for the leak assertion in tests. */
143
+ function trackedScrollerCount() {
144
+ return offsets.size;
145
+ }
146
+ //# sourceMappingURL=scrollContext.js.map
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.containsNormalizedPoint = containsNormalizedPoint;
7
+ exports.localPoint = localPoint;
8
+ exports.normalizeRect = normalizeRect;
9
+ exports.readRectSync = readRectSync;
10
+ /**
11
+ * RN-13 — THE TARGET'S RECTANGLE, without which "placed on Add to Cart" is unprovable.
12
+ *
13
+ * WHAT IS MISSING WITHOUT IT. A tap carries a point and an identity. That is enough to say "this
14
+ * tap resolved to the control named `checkout-cta`" and NOT enough to say the heat is actually ON
15
+ * that control — those are different claims, and only the second is what a heatmap draws. With no
16
+ * rect, an off-by-a-status-bar coordinate transform, a stale layout, or a mispaired identity all
17
+ * produce output that looks correct: a marker somewhere on the screen, labelled with a real
18
+ * control's name. The rect is what makes `heat ∈ target` decidable rather than assumed, which is
19
+ * why the plan moved this to P0 and ships it WITH semantic identity rather than after.
20
+ *
21
+ * WHY A SYNCHRONOUS MEASUREMENT IS THE WHOLE DESIGN CONSTRAINT. The tap handler runs synchronously
22
+ * and the interaction is emitted shortly after. React Native's classic measurement APIs
23
+ * (`measure`, `measureInWindow`) are CALLBACK-based, so a rect requested at finger-down arrives
24
+ * after the handler has returned and possibly after the event has been emitted. An implementation
25
+ * built on them would attach whichever rect happened to have resolved in time — sometimes the
26
+ * right one, sometimes the previous tap's, with no way to tell the two apart afterwards. That is
27
+ * the failure this module refuses to have.
28
+ *
29
+ * The New Architecture's host components expose `unstable_getBoundingClientRect()`, which returns
30
+ * a DOMRect SYNCHRONOUSLY. So the rect is either read in the same tick as the touch, or it is
31
+ * null. There is no third option and no best-effort.
32
+ *
33
+ * THE `unstable_` PREFIX IS LOAD-BEARING and is why every access here is a probe rather than a
34
+ * call. The method may be renamed when it stabilises, may be absent on the old architecture
35
+ * entirely, and may throw on a node that has been unmounted between the touch and the read. Each
36
+ * of those degrades to `null` — the SDK reports that it could not measure, which is a fact, rather
37
+ * than a rectangle it invented, which would be indistinguishable from a measured one.
38
+ */
39
+
40
+ /** A rectangle in the same coordinate space as `pageX`/`pageY`: the RN root view. */
41
+
42
+ /** A rectangle expressed as fractions of the capture base, directly comparable to a tap's normalized point. */
43
+
44
+ /**
45
+ * Read a node's rectangle synchronously, or return null.
46
+ *
47
+ * Both spellings are probed: `unstable_getBoundingClientRect` is what React Native 0.81 ships, and
48
+ * the unprefixed name is what it becomes if the API stabilises. Probing for both means the day it
49
+ * is renamed the SDK keeps working instead of silently losing geometry — and silently is exactly
50
+ * how it would go, because a missing rect is a legitimate outcome that produces no error.
51
+ */
52
+ function readRectSync(node) {
53
+ if (!node || typeof node !== 'object') return null;
54
+ const n = node;
55
+ const fn = typeof n.unstable_getBoundingClientRect === 'function' ? n.unstable_getBoundingClientRect : typeof n.getBoundingClientRect === 'function' ? n.getBoundingClientRect : null;
56
+ if (!fn) return null;
57
+ try {
58
+ const r = fn.call(n);
59
+ if (!r) return null;
60
+ const {
61
+ x,
62
+ y,
63
+ width,
64
+ height
65
+ } = r;
66
+ /* A zero-area rect is not a measurement of a control, it is a control that is not laid out —
67
+ collapsed, unmounted mid-gesture, or measured before layout. Reporting it would put a
68
+ degenerate box into `heat ∈ target` and every containment test against it would fail in a
69
+ way that looks like a placement bug rather than a measurement gap. */
70
+ if (![x, y, width, height].every(v => typeof v === 'number' && Number.isFinite(v))) return null;
71
+ if (width <= 0 || height <= 0) return null;
72
+ return {
73
+ x,
74
+ y,
75
+ width,
76
+ height
77
+ };
78
+ } catch {
79
+ /* An unmounted node throws. Not measurable is not an error worth propagating. */
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Express a rect as fractions of the capture base, so it is comparable to the tap's normalized point.
86
+ *
87
+ * WHY THIS SHARES THE TAP'S BASE AND NOT A WINDOW SIZE. The tap is normalized against the measured
88
+ * ROOT VIEW — the exact view the screenshot captures — precisely because `Dimensions.get('window')`
89
+ * excludes system insets differently across devices and pushes markers about 10% off vertically.
90
+ * A rect normalized against a different base than the point it will be compared with produces a
91
+ * containment test that is wrong by that same offset, which would read as "the heat is just outside
92
+ * the button" on every device with a status bar. Same base, or no rect.
93
+ *
94
+ * Values are NOT clamped to 0..1. A control genuinely extending past the capture base (a row
95
+ * scrolled half off-screen) has a rect that runs past the edge, and clamping it would silently
96
+ * enlarge the containment test — turning a tap that missed the visible part of a control into a hit.
97
+ */
98
+ function normalizeRect(rect, base) {
99
+ if (!rect || !base || base.w <= 0 || base.h <= 0) return null;
100
+ const out = {
101
+ x: rect.x / base.w,
102
+ y: rect.y / base.h,
103
+ width: rect.width / base.w,
104
+ height: rect.height / base.h
105
+ };
106
+ if (![out.x, out.y, out.width, out.height].every(v => Number.isFinite(v))) return null;
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * WHERE IN THE CONTROL the tap landed — RN-15.
112
+ *
113
+ * `u` and `v` are the tap's position as fractions of the target's own box: (0,0) is its top-left,
114
+ * (1,1) its bottom-right, (0.5,0.5) dead centre. This is what turns "they tapped Add to Cart" into
115
+ * "they tapped the left edge of Add to Cart", which is the difference between knowing a control is
116
+ * used and knowing whether its hit area is right.
117
+ *
118
+ * RESOLUTION-INDEPENDENT BY CONSTRUCTION, which is the point on mobile. A screen-space heatmap
119
+ * aggregates a control differently on every device size, so a button that sits at 40% width on a
120
+ * phone and 25% on a tablet produces two clouds of heat that never combine. Local coordinates are
121
+ * the same numbers on both, so one control is one distribution regardless of what it is rendered on.
122
+ *
123
+ * DELIBERATELY NOT CLAMPED, and this is the interesting part rather than an oversight. A value
124
+ * outside 0..1 means the tap landed OUTSIDE the control's visual box and was still attributed to
125
+ * it — which is exactly what `hitSlop` does, and it is real: `hitSlop={24}` makes a control
126
+ * respond to touches two dozen points beyond its own edge. Clamping would erase the one signal
127
+ * that distinguishes "tapped the button" from "tapped near the button and the button took it",
128
+ * and that distinction is a design finding: a control whose taps cluster outside its own bounds is
129
+ * drawn too small.
130
+ *
131
+ * Returns null, not a centre, when either input is missing. A fabricated (0.5, 0.5) would sit in
132
+ * the middle of every distribution and look exactly like a deliberate central tap.
133
+ */
134
+ function localPoint(rect, nx, ny) {
135
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
136
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
137
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
138
+ return {
139
+ u: (nx - rect.x) / rect.width,
140
+ v: (ny - rect.y) / rect.height
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Is the tap's normalized point inside the target's normalized rect?
146
+ *
147
+ * THE PREDICATE THE WHOLE MODULE EXISTS FOR. A heatmap that draws a marker labelled with a
148
+ * control's name is asserting this, and until now nothing checked it.
149
+ *
150
+ * Edges are INCLUSIVE. A tap on a button's exact border is a tap on the button — exclusive edges
151
+ * would report a sliver of every control as a miss, and at the resolution these coordinates carry
152
+ * that sliver is not a real distinction.
153
+ *
154
+ * Returns null, not false, when either input is missing. "The point is outside the control" and
155
+ * "we could not measure the control" are different findings with different fixes, and collapsing
156
+ * them into `false` would make an unmeasurable control look like a placement failure.
157
+ */
158
+ function containsNormalizedPoint(rect, nx, ny) {
159
+ if (!rect) return null;
160
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
161
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
162
+ return nx >= rect.x && nx <= rect.x + rect.width && ny >= rect.y && ny <= rect.y + rect.height;
163
+ }
164
+ //# sourceMappingURL=targetGeometry.js.map