@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
@@ -4,18 +4,138 @@ export function generateInteractionId() {
4
4
  return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
5
5
  }
6
6
 
7
+ /**
8
+ * RN-6 — THE CORRELATION CONTRACT.
9
+ *
10
+ * WHAT IS BEING JOINED, AND WHY IT IS HARD. A physical tap is observed twice: natively, by
11
+ * a window callback that sees ACTION_DOWN/UP and produces the coordinates; and in JS, by a
12
+ * capture-phase touch handler that produces the IDENTITY — which control was hit, what UI
13
+ * state was declared, which screen. Both sides mint their own id and neither can derive
14
+ * the other's: native builds `ixn-a-<process nonce>-<MotionEvent ts>` from a clock JS
15
+ * cannot read, and JS builds `ixj-<Date.now()>-<random>`. So the two observations have to
16
+ * be JOINED, and the only shared quantity is time.
17
+ *
18
+ * WHY THE OLD JOIN WAS DANGEROUS RATHER THAN MERELY APPROXIMATE. It took whichever start
19
+ * was nearest in time within ±1500 ms and used it unconditionally. The fields it carries
20
+ * across are `target`, `targetId`, `ui`, `stateStatus` and `screenName`; the coordinates
21
+ * come from the native side and are always right. So a wrong pairing does not produce
22
+ * obviously broken data — it produces heat in the CORRECT position attributed to the
23
+ * WRONG control, in the wrong UI state, on the wrong screen. Nothing downstream can
24
+ * detect that, because every field is individually well-formed.
25
+ *
26
+ * 1500 ms is a very long time on a touch surface. A deliberate double-tap is 150–300 ms
27
+ * apart; an impatient tapper is faster. So "nearest within 1500 ms" routinely had more
28
+ * than one candidate, and picked one with no record that it had a choice.
29
+ *
30
+ * WHAT THIS REPLACES IT WITH — three outcomes instead of one, and a refusal:
31
+ *
32
+ * 'exact' one candidate is clearly closest: the runner-up is at least
33
+ * `marginMs` further away. The join is used.
34
+ * 'ambiguous' two candidates are within `marginMs` of each other, so choosing
35
+ * between them is a coin flip. The join is REFUSED and the caller must
36
+ * emit the interaction WITHOUT js-derived identity. A tap with no target
37
+ * is honest and visibly incomplete; a tap with the wrong target is
38
+ * indistinguishable from a correct one and corrupts every aggregate it
39
+ * enters.
40
+ * 'none' nothing within tolerance. Same refusal, different reason — worth
41
+ * separating because 'none' means the JS handler never fired (a real
42
+ * capture gap) while 'ambiguous' means it fired too often to attribute.
43
+ *
44
+ * It also reports `deltaMs` and `runnerUpDeltaMs` so the outcome is auditable after the
45
+ * fact rather than being a claim this comment makes.
46
+ *
47
+ * WHAT IT DELIBERATELY DOES NOT DO: use position to disambiguate. The native event carries
48
+ * raw px in the capture view and normalized 0..1 coordinates; the JS start carries its own
49
+ * x/y in a different space (dp in the root view). Comparing them needs a proven scale
50
+ * factor between the two, and getting it wrong would REJECT valid pairings — strictly
51
+ * worse than today. That calibration needs a device run, so it is a follow-up, not a
52
+ * guess made here.
53
+ */
54
+
55
+ /**
56
+ * How far apart a native event and a JS start may be and still be the same touch.
57
+ *
58
+ * Unchanged at 1500 ms from the previous implementation, deliberately: narrowing it is a
59
+ * separate, measurable decision about how late the JS handler can run on a slow device,
60
+ * and bundling it into this change would make any regression impossible to attribute.
61
+ */
62
+ export const CORRELATION_TOLERANCE_MS = 1500;
63
+
64
+ /**
65
+ * How much closer the winner must be than the runner-up to count as identified.
66
+ *
67
+ * 100 ms because that is comfortably below the fastest deliberate double-tap (~150 ms):
68
+ * when two candidate starts are within 100 ms of each other in distance, the native event
69
+ * sits effectively between them and the "nearest" one is a coin flip.
70
+ */
71
+ export const CORRELATION_MARGIN_MS = 100;
72
+
7
73
  /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
8
- export function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
9
- let best;
10
- let bestDelta = toleranceMs + 1;
11
- for (const start of starts) {
74
+ export function correlateInteraction(starts, occurredAt, toleranceMs = CORRELATION_TOLERANCE_MS, marginMs = CORRELATION_MARGIN_MS) {
75
+ const candidates = [];
76
+ for (let i = 0; i < starts.length; i++) {
77
+ const start = starts[i];
78
+ if (!start) continue;
12
79
  const delta = Math.abs(start.occurredAt - occurredAt);
13
- if (delta < bestDelta) {
14
- best = start;
15
- bestDelta = delta;
80
+ if (delta <= toleranceMs) candidates.push({
81
+ index: i,
82
+ delta
83
+ });
84
+ }
85
+ if (candidates.length === 0) {
86
+ return {
87
+ index: -1,
88
+ outcome: 'none',
89
+ deltaMs: null,
90
+ runnerUpDeltaMs: null
91
+ };
92
+ }
93
+
94
+ /* Stable ordering: equal deltas resolve by index so a repeated run cannot produce a
95
+ different answer from the same inputs. A non-deterministic correlation would make
96
+ every downstream measurement unreproducible. */
97
+ candidates.sort((a, b) => a.delta - b.delta || a.index - b.index);
98
+ const best = candidates[0];
99
+ const runnerUp = candidates[1];
100
+ if (runnerUp && runnerUp.delta - best.delta < marginMs) {
101
+ return {
102
+ index: -1,
103
+ outcome: 'ambiguous',
104
+ deltaMs: best.delta,
105
+ runnerUpDeltaMs: runnerUp.delta
106
+ };
107
+ }
108
+ return {
109
+ start: starts[best.index],
110
+ index: best.index,
111
+ outcome: 'exact',
112
+ deltaMs: best.delta,
113
+ runnerUpDeltaMs: runnerUp ? runnerUp.delta : null
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Drop starts that can no longer match anything, in place.
119
+ *
120
+ * WHY IT MATTERS BEYOND MEMORY. A start older than the tolerance is unreachable, but while
121
+ * it sits in the list it is still a CANDIDATE for the ambiguity test — so a stale entry can
122
+ * turn a perfectly clear pairing into a refusal, and an accumulating list makes that more
123
+ * likely the longer a session runs. Pruning is therefore part of the correctness of the
124
+ * join, not housekeeping.
125
+ *
126
+ * Mutates rather than returning a copy because the caller holds it in a ref that the native
127
+ * listener and the touch handler both read.
128
+ */
129
+ export function pruneInteractionStarts(starts, now, toleranceMs = CORRELATION_TOLERANCE_MS) {
130
+ let removed = 0;
131
+ for (let i = starts.length - 1; i >= 0; i--) {
132
+ const start = starts[i];
133
+ if (!start || now - start.occurredAt > toleranceMs) {
134
+ starts.splice(i, 1);
135
+ removed++;
16
136
  }
17
137
  }
18
- return bestDelta <= toleranceMs ? best : undefined;
138
+ return removed;
19
139
  }
20
140
 
21
141
  /** Analytics is a projection of the same evidence; no second click is invented. */
@@ -0,0 +1,135 @@
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
+ /**
39
+ * How long a recorded offset stays usable.
40
+ *
41
+ * A scroller that has not moved in this long may well have been unmounted — a list on a screen the
42
+ * user left — and its stale offset must not be attached to a tap on a DIFFERENT screen that happens
43
+ * to reuse the same native tag. Tags are recycled by the platform, which makes this a correctness
44
+ * bound rather than a memory one.
45
+ *
46
+ * Five minutes is far longer than any plausible gap between scrolling a list and tapping in it,
47
+ * and far shorter than a session.
48
+ */
49
+ export const SCROLL_OFFSET_TTL_MS = 5 * 60_000;
50
+
51
+ /**
52
+ * Cap on tracked scrollers.
53
+ *
54
+ * Bounded because a long session on a virtualised screen can mount and unmount many scrollers, and
55
+ * an unbounded map is a leak that only shows up in the sessions that matter most — the long ones.
56
+ * Eviction is oldest-first, which is the entry least likely to be the one a tap needs.
57
+ */
58
+ const MAX_TRACKED_SCROLLERS = 32;
59
+ const offsets = new Map();
60
+
61
+ /**
62
+ * The native tag of a host instance, across the shapes React Native has used.
63
+ *
64
+ * Fabric exposes `__nativeTag`; the old architecture used `_nativeTag`. Probing both means this
65
+ * keeps working on either architecture, and returns null rather than throwing on anything else —
66
+ * a scroll offset is never worth a crash.
67
+ */
68
+ export function nativeTagOf(node) {
69
+ if (!node || typeof node !== 'object') return null;
70
+ const n = node;
71
+ const tag = typeof n.__nativeTag === 'number' ? n.__nativeTag : n._nativeTag;
72
+ return typeof tag === 'number' && Number.isFinite(tag) ? tag : null;
73
+ }
74
+
75
+ /** Record where a scroller currently sits. Called on every scroll event of a wrapped scroller. */
76
+ export function recordScrollOffset(tag, x, y, now = Date.now()) {
77
+ if (tag === null || !Number.isFinite(x) || !Number.isFinite(y)) return;
78
+ offsets.set(tag, {
79
+ x,
80
+ y,
81
+ at: now
82
+ });
83
+ if (offsets.size > MAX_TRACKED_SCROLLERS) {
84
+ let oldestTag = null;
85
+ let oldestAt = Infinity;
86
+ for (const [k, v] of offsets) {
87
+ if (v.at < oldestAt) {
88
+ oldestAt = v.at;
89
+ oldestTag = k;
90
+ }
91
+ }
92
+ if (oldestTag !== null) offsets.delete(oldestTag);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The offset of the NEAREST ancestor scroller that has one, given the tapped node's ancestor tags
98
+ * ordered innermost first.
99
+ *
100
+ * Nearest wins because that is the scroller the content under the finger actually moved with. A
101
+ * tap inside a horizontal carousel inside a vertical list belongs to the carousel's content
102
+ * position; attributing it to the outer list would place it by how far the PAGE had scrolled,
103
+ * which is the merge this whole module exists to prevent.
104
+ *
105
+ * Returns null when no ancestor has a recorded offset — never a zero.
106
+ */
107
+ export function nearestRecordedOffset(ancestorTags, now = Date.now()) {
108
+ for (const tag of ancestorTags) {
109
+ if (tag === null) continue;
110
+ const hit = offsets.get(tag);
111
+ if (!hit) continue;
112
+ if (now - hit.at > SCROLL_OFFSET_TTL_MS) {
113
+ /* Expired: drop it so a recycled tag cannot serve a previous screen's offset. */
114
+ offsets.delete(tag);
115
+ continue;
116
+ }
117
+ return {
118
+ x: hit.x,
119
+ y: hit.y,
120
+ tag
121
+ };
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** Forget everything. Called between sessions so one recording cannot inherit another's scrollers. */
127
+ export function clearScrollOffsets() {
128
+ offsets.clear();
129
+ }
130
+
131
+ /** How many scrollers are currently tracked — for the leak assertion in tests. */
132
+ export function trackedScrollerCount() {
133
+ return offsets.size;
134
+ }
135
+ //# sourceMappingURL=scrollContext.js.map
@@ -0,0 +1,155 @@
1
+ /**
2
+ * RN-13 — THE TARGET'S RECTANGLE, without which "placed on Add to Cart" is unprovable.
3
+ *
4
+ * WHAT IS MISSING WITHOUT IT. A tap carries a point and an identity. That is enough to say "this
5
+ * tap resolved to the control named `checkout-cta`" and NOT enough to say the heat is actually ON
6
+ * that control — those are different claims, and only the second is what a heatmap draws. With no
7
+ * rect, an off-by-a-status-bar coordinate transform, a stale layout, or a mispaired identity all
8
+ * produce output that looks correct: a marker somewhere on the screen, labelled with a real
9
+ * control's name. The rect is what makes `heat ∈ target` decidable rather than assumed, which is
10
+ * why the plan moved this to P0 and ships it WITH semantic identity rather than after.
11
+ *
12
+ * WHY A SYNCHRONOUS MEASUREMENT IS THE WHOLE DESIGN CONSTRAINT. The tap handler runs synchronously
13
+ * and the interaction is emitted shortly after. React Native's classic measurement APIs
14
+ * (`measure`, `measureInWindow`) are CALLBACK-based, so a rect requested at finger-down arrives
15
+ * after the handler has returned and possibly after the event has been emitted. An implementation
16
+ * built on them would attach whichever rect happened to have resolved in time — sometimes the
17
+ * right one, sometimes the previous tap's, with no way to tell the two apart afterwards. That is
18
+ * the failure this module refuses to have.
19
+ *
20
+ * The New Architecture's host components expose `unstable_getBoundingClientRect()`, which returns
21
+ * a DOMRect SYNCHRONOUSLY. So the rect is either read in the same tick as the touch, or it is
22
+ * null. There is no third option and no best-effort.
23
+ *
24
+ * THE `unstable_` PREFIX IS LOAD-BEARING and is why every access here is a probe rather than a
25
+ * call. The method may be renamed when it stabilises, may be absent on the old architecture
26
+ * entirely, and may throw on a node that has been unmounted between the touch and the read. Each
27
+ * of those degrades to `null` — the SDK reports that it could not measure, which is a fact, rather
28
+ * than a rectangle it invented, which would be indistinguishable from a measured one.
29
+ */
30
+
31
+ /** A rectangle in the same coordinate space as `pageX`/`pageY`: the RN root view. */
32
+
33
+ /** A rectangle expressed as fractions of the capture base, directly comparable to a tap's normalized point. */
34
+
35
+ /**
36
+ * Read a node's rectangle synchronously, or return null.
37
+ *
38
+ * Both spellings are probed: `unstable_getBoundingClientRect` is what React Native 0.81 ships, and
39
+ * the unprefixed name is what it becomes if the API stabilises. Probing for both means the day it
40
+ * is renamed the SDK keeps working instead of silently losing geometry — and silently is exactly
41
+ * how it would go, because a missing rect is a legitimate outcome that produces no error.
42
+ */
43
+ export function readRectSync(node) {
44
+ if (!node || typeof node !== 'object') return null;
45
+ const n = node;
46
+ const fn = typeof n.unstable_getBoundingClientRect === 'function' ? n.unstable_getBoundingClientRect : typeof n.getBoundingClientRect === 'function' ? n.getBoundingClientRect : null;
47
+ if (!fn) return null;
48
+ try {
49
+ const r = fn.call(n);
50
+ if (!r) return null;
51
+ const {
52
+ x,
53
+ y,
54
+ width,
55
+ height
56
+ } = r;
57
+ /* A zero-area rect is not a measurement of a control, it is a control that is not laid out —
58
+ collapsed, unmounted mid-gesture, or measured before layout. Reporting it would put a
59
+ degenerate box into `heat ∈ target` and every containment test against it would fail in a
60
+ way that looks like a placement bug rather than a measurement gap. */
61
+ if (![x, y, width, height].every(v => typeof v === 'number' && Number.isFinite(v))) return null;
62
+ if (width <= 0 || height <= 0) return null;
63
+ return {
64
+ x,
65
+ y,
66
+ width,
67
+ height
68
+ };
69
+ } catch {
70
+ /* An unmounted node throws. Not measurable is not an error worth propagating. */
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Express a rect as fractions of the capture base, so it is comparable to the tap's normalized point.
77
+ *
78
+ * WHY THIS SHARES THE TAP'S BASE AND NOT A WINDOW SIZE. The tap is normalized against the measured
79
+ * ROOT VIEW — the exact view the screenshot captures — precisely because `Dimensions.get('window')`
80
+ * excludes system insets differently across devices and pushes markers about 10% off vertically.
81
+ * A rect normalized against a different base than the point it will be compared with produces a
82
+ * containment test that is wrong by that same offset, which would read as "the heat is just outside
83
+ * the button" on every device with a status bar. Same base, or no rect.
84
+ *
85
+ * Values are NOT clamped to 0..1. A control genuinely extending past the capture base (a row
86
+ * scrolled half off-screen) has a rect that runs past the edge, and clamping it would silently
87
+ * enlarge the containment test — turning a tap that missed the visible part of a control into a hit.
88
+ */
89
+ export function normalizeRect(rect, base) {
90
+ if (!rect || !base || base.w <= 0 || base.h <= 0) return null;
91
+ const out = {
92
+ x: rect.x / base.w,
93
+ y: rect.y / base.h,
94
+ width: rect.width / base.w,
95
+ height: rect.height / base.h
96
+ };
97
+ if (![out.x, out.y, out.width, out.height].every(v => Number.isFinite(v))) return null;
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * WHERE IN THE CONTROL the tap landed — RN-15.
103
+ *
104
+ * `u` and `v` are the tap's position as fractions of the target's own box: (0,0) is its top-left,
105
+ * (1,1) its bottom-right, (0.5,0.5) dead centre. This is what turns "they tapped Add to Cart" into
106
+ * "they tapped the left edge of Add to Cart", which is the difference between knowing a control is
107
+ * used and knowing whether its hit area is right.
108
+ *
109
+ * RESOLUTION-INDEPENDENT BY CONSTRUCTION, which is the point on mobile. A screen-space heatmap
110
+ * aggregates a control differently on every device size, so a button that sits at 40% width on a
111
+ * phone and 25% on a tablet produces two clouds of heat that never combine. Local coordinates are
112
+ * the same numbers on both, so one control is one distribution regardless of what it is rendered on.
113
+ *
114
+ * DELIBERATELY NOT CLAMPED, and this is the interesting part rather than an oversight. A value
115
+ * outside 0..1 means the tap landed OUTSIDE the control's visual box and was still attributed to
116
+ * it — which is exactly what `hitSlop` does, and it is real: `hitSlop={24}` makes a control
117
+ * respond to touches two dozen points beyond its own edge. Clamping would erase the one signal
118
+ * that distinguishes "tapped the button" from "tapped near the button and the button took it",
119
+ * and that distinction is a design finding: a control whose taps cluster outside its own bounds is
120
+ * drawn too small.
121
+ *
122
+ * Returns null, not a centre, when either input is missing. A fabricated (0.5, 0.5) would sit in
123
+ * the middle of every distribution and look exactly like a deliberate central tap.
124
+ */
125
+ export function localPoint(rect, nx, ny) {
126
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
127
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
128
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
129
+ return {
130
+ u: (nx - rect.x) / rect.width,
131
+ v: (ny - rect.y) / rect.height
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Is the tap's normalized point inside the target's normalized rect?
137
+ *
138
+ * THE PREDICATE THE WHOLE MODULE EXISTS FOR. A heatmap that draws a marker labelled with a
139
+ * control's name is asserting this, and until now nothing checked it.
140
+ *
141
+ * Edges are INCLUSIVE. A tap on a button's exact border is a tap on the button — exclusive edges
142
+ * would report a sliver of every control as a miss, and at the resolution these coordinates carry
143
+ * that sliver is not a real distinction.
144
+ *
145
+ * Returns null, not false, when either input is missing. "The point is outside the control" and
146
+ * "we could not measure the control" are different findings with different fixes, and collapsing
147
+ * them into `false` would make an unmeasurable control look like a placement failure.
148
+ */
149
+ export function containsNormalizedPoint(rect, nx, ny) {
150
+ if (!rect) return null;
151
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
152
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
153
+ return nx >= rect.x && nx <= rect.x + rect.width && ny >= rect.y && ny <= rect.y + rect.height;
154
+ }
155
+ //# sourceMappingURL=targetGeometry.js.map
@@ -9,19 +9,70 @@
9
9
  * WHY THE FIBER. There is no DOM. The only place a tapped view's identity exists in JS is the React
10
10
  * fiber hanging off the touch event (`_targetInst`), which carries `memoizedProps` — where
11
11
  * `accessibilityLabel` and `testID` live. This is a React internal, so every access is defensive and
12
- * the whole thing degrades to `null` rather than throwing: a analytics label is never worth a crash.
12
+ * the whole thing degrades to `null` rather than throwing: an analytics label is never worth a crash.
13
13
  *
14
- * IDENTITY PREFERENCE, strongest first:
15
- * 1. `accessibilityLabel`authored to describe the control, and already required for a11y.
16
- * 2. `testID` — authored to identify the control, stable across copy changes.
17
- * 3. a literal string child — the button's visible caption.
18
- * 4. the nearest named component — coarse, but far better than nothing.
14
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
15
+ * RN-2THE IDENTITY LADDER USED TO CAPTURE USER TEXT, AND THAT IS A PRIVACY DEFECT, NOT A
16
+ * TRADE-OFF.
17
+ *
18
+ * The previous preference order was:
19
+ *
20
+ * 1. accessibilityLabel 2. testID 3. a literal string child 4. nearest component
21
+ *
22
+ * Ranks 1 and 3 are FREE TEXT authored for humans, and in a real app they routinely contain user
23
+ * data. An accessible order row is labelled "Order 1042 for mohamed@scalebun.com". A pay button
24
+ * reads "Pay EGP 1,442". A contact row's caption is a person's name. Rank 1 was preferred over
25
+ * `testID`, so the leakiest source won whenever it existed.
26
+ *
27
+ * Two separate harms, and the second is easy to miss:
28
+ *
29
+ * PRIVACY. `describeTouchTarget`'s output is persisted as the interaction's `target`. So names,
30
+ * email addresses and amounts were being written into an analytics store, under a field nobody
31
+ * would think to audit for PII. The same product already found 27 rows in `replay_events`
32
+ * carrying email addresses through a different route; this was a second one.
33
+ *
34
+ * CORRECTNESS. `target` is an AGGREGATION KEY. Keyed on text that varies per user, a single
35
+ * button produces one bucket per person — so the grouping this feature exists to provide is
36
+ * destroyed precisely in the screens that matter most. A leaky key is also a useless key.
37
+ *
38
+ * SO THE LADDER IS NOW DERIVED FROM CODE, NEVER FROM CONTENT:
39
+ *
40
+ * 1. `testID` — authored to identify, stable across copy edits, and by convention never
41
+ * user data. The strongest signal and now the first.
42
+ * 2. `path` — the component-name chain from the tapped node upward, e.g.
43
+ * `CartScreen>OrderRow>Pressable`. Structural: it changes when the code
44
+ * changes, not when the data does. This is what makes the ladder useful in
45
+ * the overwhelming majority of apps, which have no testIDs — without it,
46
+ * removing free text would have meant removing identity.
47
+ * 3. `component` — the nearest named component alone. Coarse, but a code identifier.
48
+ *
49
+ * Free text is still RESOLVABLE, because a team that has audited its own labels may legitimately
50
+ * want them, but it is opt-in per call (`includeText`) and it is NEVER what
51
+ * `describeTouchTarget` returns. Default-off matters more than the option: a default that leaks
52
+ * is a leak, whatever the documentation says.
53
+ *
54
+ * WHY THIS SHIPS WITH RN-1 (persistence) RATHER THAN AFTER IT. RN-1 makes the resolved target
55
+ * actually reach the backend. Landing that first would persist the leaky ladder and create a
56
+ * historical PII problem to clean up; landing the sanitised ladder first and persistence second
57
+ * leaves a window where the feature looks broken. They are one change.
58
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
19
59
  */
20
60
 
61
+ /** Where a resolved identity came from — so a consumer can tell an authored id from a guess. */
62
+
21
63
  /** How far up the tree to look before giving up. Deep enough for a wrapped button, cheap enough to run on every tap. */
22
64
  const MAX_DEPTH = 12;
23
65
  /** Labels longer than this are truncated — a caption, not a paragraph. */
24
66
  const MAX_LEN = 60;
67
+ /**
68
+ * How many component names the structural path may hold.
69
+ *
70
+ * Four is enough to separate two same-named controls in different containers
71
+ * (`Pressable<OrderRow<CartScreen` vs `Pressable<SuggestionRow<CartScreen`) without letting the
72
+ * key drift every time someone adds a wrapper view higher up. A path that changes on unrelated
73
+ * refactors is a key that silently splits its own history.
74
+ */
75
+ const MAX_PATH_SEGMENTS = 4;
25
76
  function str(v) {
26
77
  if (typeof v !== 'string') return undefined;
27
78
  const s = v.trim();
@@ -41,7 +92,7 @@ function componentName(f) {
41
92
  return n.slice(0, MAX_LEN);
42
93
  }
43
94
 
44
- /** First literal string child, e.g. <Text>Buy now</Text>. */
95
+ /** First literal string child, e.g. <Text>Buy now</Text>. FREE TEXT — see the header. */
45
96
  function textChild(props) {
46
97
  if (!props) return undefined;
47
98
  const c = props.children;
@@ -61,10 +112,9 @@ function fiberOf(event) {
61
112
  const e = event;
62
113
  const direct = e._targetInst;
63
114
  if (direct && typeof direct === 'object') return direct;
64
- // Newer RN exposes the instance handle on the target node instead.
65
- const target = e.target;
66
- if (target && typeof target === 'object') {
67
- const handle = target._internalFiberInstanceHandleDEV ?? target._internalInstanceHandle;
115
+ const target = e.target ?? e.nativeEvent?.target;
116
+ if (target) {
117
+ const handle = target._internalInstanceHandle ?? target._targetInst;
68
118
  if (handle && typeof handle === 'object') return handle;
69
119
  }
70
120
  return null;
@@ -73,42 +123,119 @@ function fiberOf(event) {
73
123
  /**
74
124
  * Walk up from the touched fiber collecting the best identity available.
75
125
  *
76
- * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf only had text,
77
- * but stops as soon as BOTH an accessibilityLabel and a testID are known — nothing above can improve on
78
- * that pair.
126
+ * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf had none,
127
+ * and stops as soon as a `testID` is known AND the path is full — nothing above can improve on that.
79
128
  */
80
- export function resolveTouchTarget(event, maxDepth = MAX_DEPTH) {
129
+ export function resolveTouchTarget(event, optsOrMaxDepth = {}) {
130
+ /* Number form kept for the previous signature `resolveTouchTarget(event, maxDepth)`. */
131
+ const opts = typeof optsOrMaxDepth === 'number' ? {
132
+ maxDepth: optsOrMaxDepth
133
+ } : optsOrMaxDepth;
134
+ const maxDepth = opts.maxDepth ?? MAX_DEPTH;
81
135
  let f = fiberOf(event);
82
136
  if (!f) return null;
83
137
  const out = {};
138
+ const segments = [];
139
+ const ancestors = [];
84
140
  let depth = 0;
85
141
  try {
86
142
  while (f && depth < maxDepth) {
87
143
  const props = f.memoizedProps;
88
144
  if (props) {
89
- out.accessibilityLabel ??= str(props.accessibilityLabel);
145
+ const beforeTestId = out.testID;
90
146
  out.testID ??= str(props.testID);
91
- out.text ??= textChild(props);
147
+ /* The node is pinned to the fiber that supplied the testID — the CONTROL, not the
148
+ deepest node hit. A Text inside a Pressable is a tap on the Pressable, so the rect
149
+ has to be the Pressable's or the containment test would be run against the caption. */
150
+ if (!beforeTestId && out.testID) out.node = f.stateNode;
151
+ /* The nearest enclosing cell wins: nested lists mean a row can sit inside another
152
+ row, and the innermost is the one the finger is actually in. `??=` keeps the first
153
+ seen while walking outward, which is the innermost. */
154
+ if (typeof props.cellKey === 'string' && props.cellKey) {
155
+ out.itemKey ??= str(props.cellKey);
156
+ if (typeof props.index === 'number' && Number.isFinite(props.index)) {
157
+ out.itemIndex ??= props.index;
158
+ }
159
+ }
160
+ if (opts.includeText) {
161
+ out.accessibilityLabel ??= str(props.accessibilityLabel);
162
+ out.text ??= textChild(props);
163
+ }
92
164
  }
93
- out.component ??= componentName(f);
94
- if (out.accessibilityLabel && out.testID) break;
165
+ /* Host instances only — a composite fiber has no native tag, so keeping them would pad
166
+ the chain with entries no scroller could ever match. */
167
+ if (f.stateNode) ancestors.push(f.stateNode);
168
+ const name = componentName(f);
169
+ if (name) {
170
+ out.component ??= name;
171
+ /* Consecutive duplicates collapse: a component that renders itself through a memo or a
172
+ forwardRef appears twice in the fiber chain and would otherwise pad the path with a
173
+ repetition that carries no information. */
174
+ if (segments[segments.length - 1] !== name && segments.length < MAX_PATH_SEGMENTS) {
175
+ segments.push(name);
176
+ }
177
+ }
178
+ if (out.testID && segments.length >= MAX_PATH_SEGMENTS) break;
95
179
  f = f.return ?? null;
96
180
  depth++;
97
181
  }
98
182
  } catch {
99
183
  /* React internals shifted under us — return whatever was gathered so far */
100
184
  }
101
- return out.accessibilityLabel || out.testID || out.text || out.component ? out : null;
185
+
186
+ /* Tapped node first, so the most specific segment leads and a truncated path is still anchored
187
+ to the thing actually touched. */
188
+ if (segments.length) out.path = segments.join('<');
189
+
190
+ /* No authored testID anywhere, so identity came from the structural path. Anchor the rect to
191
+ the node that was actually touched — imperfect (it may be the caption rather than the
192
+ control) but honest, and better than no geometry for the majority of apps, which author no
193
+ testIDs. `targetSource` records which case this was, so a consumer can weight it. */
194
+ if (!out.node) out.node = fiberOf(event)?.stateNode;
195
+ if (ancestors.length) out.ancestors = ancestors;
196
+ return out.testID || out.path || out.component || out.accessibilityLabel || out.text || out.itemKey ? out : null;
102
197
  }
103
198
 
104
199
  /**
105
- * One short human label for the tapped thing, or undefined when nothing identified it.
200
+ * One short, STABLE, non-identifying label for the tapped thing.
106
201
  *
107
- * This is what ends up grouping the Events explorer, so it must be STABLE: a11y label and testID are
108
- * authored and rarely change, whereas visible text moves with copy edits hence the ordering.
202
+ * This is what ends up grouping the Events explorer and what gets persisted as the interaction's
203
+ * target, so it must satisfy two constraints at once: stable across copy edits, and free of user
204
+ * data. Both rule out visible text, which is why the free-text fields are not consulted here even
205
+ * when they have been resolved — see the RN-2 note in the header.
109
206
  */
110
207
  export function describeTouchTarget(t) {
111
208
  if (!t) return undefined;
112
- return t.accessibilityLabel || t.testID || t.text || t.component;
209
+ return t.testID || t.path || t.component;
210
+ }
211
+
212
+ /**
213
+ * The identity plus WHERE IT CAME FROM.
214
+ *
215
+ * A consumer cannot treat an authored `testID` and a structural `path` as equally trustworthy: the
216
+ * first is a promise from the app's authors that this control is this control, the second is an
217
+ * inference that breaks when the tree is refactored. Returning the source lets the accounting lane
218
+ * say WHY a tap could not be attributed, instead of reporting a missing target and leaving the
219
+ * reader to guess whether the control had no identity or the walk failed.
220
+ */
221
+ export function targetIdentity(t) {
222
+ if (!t) return {
223
+ source: 'none'
224
+ };
225
+ if (t.testID) return {
226
+ id: t.testID,
227
+ source: 'testID'
228
+ };
229
+ if (t.path) return {
230
+ id: t.path,
231
+ source: 'path'
232
+ };
233
+ if (t.component) return {
234
+ id: t.component,
235
+ source: 'component'
236
+ };
237
+ return {
238
+ source: 'none'
239
+ };
113
240
  }
114
241
  //# sourceMappingURL=touchTarget.js.map