@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.
Files changed (115) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
  8. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  9. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
  10. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  11. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
  12. package/dist/scalebun.full.js +964 -533
  13. package/dist/scalebun.slim.js +962 -531
  14. package/ios/Ota/OtaProtocol.swift +242 -0
  15. package/ios/Ota/OtaSlotManager.swift +26 -0
  16. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  17. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  18. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  19. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  20. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  21. package/ios/ReplaySdk.swift +65 -2
  22. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  23. package/lib/commonjs/core/config/schema.js +19 -12
  24. package/lib/commonjs/core/constants/version.js +1 -1
  25. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  26. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  27. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  28. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  29. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  30. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  31. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  32. package/lib/commonjs/features/journey/uiState.js +81 -1
  33. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  34. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  35. package/lib/commonjs/features/network/thirdParty.js +90 -0
  36. package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
  37. package/lib/commonjs/features/session/SessionManager.js +63 -0
  38. package/lib/commonjs/public/ScaleBunFacade.js +23 -57
  39. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  40. package/lib/module/core/config/schema.js +19 -12
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  43. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  44. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  45. package/lib/module/features/journey/interactionProtocol.js +128 -8
  46. package/lib/module/features/journey/scrollContext.js +135 -0
  47. package/lib/module/features/journey/targetGeometry.js +155 -0
  48. package/lib/module/features/journey/touchTarget.js +151 -24
  49. package/lib/module/features/journey/uiState.js +78 -1
  50. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  51. package/lib/module/features/network/NetworkFeature.js +38 -4
  52. package/lib/module/features/network/thirdParty.js +83 -0
  53. package/lib/module/features/ota/OtaOrchestrator.js +175 -80
  54. package/lib/module/features/session/SessionManager.js +63 -0
  55. package/lib/module/public/ScaleBunFacade.js +23 -57
  56. package/lib/typescript/core/config/schema.d.ts +2 -3
  57. package/lib/typescript/core/constants/version.d.ts +1 -1
  58. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  59. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  60. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  61. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  62. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  63. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  64. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  65. package/lib/typescript/features/network/index.d.ts +18 -0
  66. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  67. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
  68. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  69. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  70. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  71. package/lib/typescript/public/types.d.ts +10 -0
  72. package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
  73. package/package.json +3 -13
  74. package/scalebun-react-native.podspec +4 -0
  75. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  76. package/src/core/config/schema.ts +21 -9
  77. package/src/core/constants/version.ts +1 -1
  78. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  79. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  80. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  81. package/src/features/journey/interactionProtocol.ts +188 -10
  82. package/src/features/journey/scrollContext.ts +137 -0
  83. package/src/features/journey/targetGeometry.ts +176 -0
  84. package/src/features/journey/touchTarget.ts +237 -26
  85. package/src/features/journey/uiState.ts +80 -1
  86. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  87. package/src/features/network/NetworkFeature.ts +41 -4
  88. package/src/features/network/index.ts +18 -0
  89. package/src/features/network/thirdParty.ts +92 -0
  90. package/src/features/ota/OtaOrchestrator.ts +190 -90
  91. package/src/features/ota/OtaTypes.ts +23 -1
  92. package/src/features/session/SessionManager.ts +113 -0
  93. package/src/public/ScaleBunFacade.ts +23 -69
  94. package/src/public/types.ts +10 -0
  95. package/src/specs/NativeScaleBunOta.ts +35 -20
  96. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  97. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  98. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  99. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  100. package/lib/commonjs/features/ota/signature.js +0 -175
  101. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  102. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  103. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  104. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  105. package/lib/module/features/ota/signature.js +0 -169
  106. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  107. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  108. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  109. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  110. package/lib/typescript/features/ota/signature.d.ts +0 -81
  111. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  112. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  113. package/src/features/ota/crypto/loadSha512.ts +0 -35
  114. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  115. package/src/features/ota/signature.ts +0 -206
@@ -0,0 +1,176 @@
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
+ export interface ElementRect {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ }
38
+
39
+ /** A rectangle expressed as fractions of the capture base, directly comparable to a tap's normalized point. */
40
+ export interface NormalizedRect {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ }
46
+
47
+ interface RectCapableNode {
48
+ unstable_getBoundingClientRect?: () => { x: number; y: number; width: number; height: number } | null;
49
+ getBoundingClientRect?: () => { x: number; y: number; width: number; height: number } | null;
50
+ }
51
+
52
+ /**
53
+ * Read a node's rectangle synchronously, or return null.
54
+ *
55
+ * Both spellings are probed: `unstable_getBoundingClientRect` is what React Native 0.81 ships, and
56
+ * the unprefixed name is what it becomes if the API stabilises. Probing for both means the day it
57
+ * is renamed the SDK keeps working instead of silently losing geometry — and silently is exactly
58
+ * how it would go, because a missing rect is a legitimate outcome that produces no error.
59
+ */
60
+ export function readRectSync(node: unknown): ElementRect | null {
61
+ if (!node || typeof node !== 'object') return null;
62
+ const n = node as RectCapableNode;
63
+ const fn =
64
+ typeof n.unstable_getBoundingClientRect === 'function'
65
+ ? n.unstable_getBoundingClientRect
66
+ : typeof n.getBoundingClientRect === 'function'
67
+ ? n.getBoundingClientRect
68
+ : null;
69
+ if (!fn) return null;
70
+ try {
71
+ const r = fn.call(n);
72
+ if (!r) return null;
73
+ const { x, y, width, height } = r;
74
+ /* A zero-area rect is not a measurement of a control, it is a control that is not laid out —
75
+ collapsed, unmounted mid-gesture, or measured before layout. Reporting it would put a
76
+ degenerate box into `heat ∈ target` and every containment test against it would fail in a
77
+ way that looks like a placement bug rather than a measurement gap. */
78
+ if (![x, y, width, height].every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
79
+ if (width <= 0 || height <= 0) return null;
80
+ return { x, y, width, height };
81
+ } catch {
82
+ /* An unmounted node throws. Not measurable is not an error worth propagating. */
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Express a rect as fractions of the capture base, so it is comparable to the tap's normalized point.
89
+ *
90
+ * WHY THIS SHARES THE TAP'S BASE AND NOT A WINDOW SIZE. The tap is normalized against the measured
91
+ * ROOT VIEW — the exact view the screenshot captures — precisely because `Dimensions.get('window')`
92
+ * excludes system insets differently across devices and pushes markers about 10% off vertically.
93
+ * A rect normalized against a different base than the point it will be compared with produces a
94
+ * containment test that is wrong by that same offset, which would read as "the heat is just outside
95
+ * the button" on every device with a status bar. Same base, or no rect.
96
+ *
97
+ * Values are NOT clamped to 0..1. A control genuinely extending past the capture base (a row
98
+ * scrolled half off-screen) has a rect that runs past the edge, and clamping it would silently
99
+ * enlarge the containment test — turning a tap that missed the visible part of a control into a hit.
100
+ */
101
+ export function normalizeRect(rect: ElementRect | null, base: { w: number; h: number } | null): NormalizedRect | null {
102
+ if (!rect || !base || base.w <= 0 || base.h <= 0) return null;
103
+ const out = {
104
+ x: rect.x / base.w,
105
+ y: rect.y / base.h,
106
+ width: rect.width / base.w,
107
+ height: rect.height / base.h,
108
+ };
109
+ if (![out.x, out.y, out.width, out.height].every((v) => Number.isFinite(v))) return null;
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * WHERE IN THE CONTROL the tap landed — RN-15.
115
+ *
116
+ * `u` and `v` are the tap's position as fractions of the target's own box: (0,0) is its top-left,
117
+ * (1,1) its bottom-right, (0.5,0.5) dead centre. This is what turns "they tapped Add to Cart" into
118
+ * "they tapped the left edge of Add to Cart", which is the difference between knowing a control is
119
+ * used and knowing whether its hit area is right.
120
+ *
121
+ * RESOLUTION-INDEPENDENT BY CONSTRUCTION, which is the point on mobile. A screen-space heatmap
122
+ * aggregates a control differently on every device size, so a button that sits at 40% width on a
123
+ * phone and 25% on a tablet produces two clouds of heat that never combine. Local coordinates are
124
+ * the same numbers on both, so one control is one distribution regardless of what it is rendered on.
125
+ *
126
+ * DELIBERATELY NOT CLAMPED, and this is the interesting part rather than an oversight. A value
127
+ * outside 0..1 means the tap landed OUTSIDE the control's visual box and was still attributed to
128
+ * it — which is exactly what `hitSlop` does, and it is real: `hitSlop={24}` makes a control
129
+ * respond to touches two dozen points beyond its own edge. Clamping would erase the one signal
130
+ * that distinguishes "tapped the button" from "tapped near the button and the button took it",
131
+ * and that distinction is a design finding: a control whose taps cluster outside its own bounds is
132
+ * drawn too small.
133
+ *
134
+ * Returns null, not a centre, when either input is missing. A fabricated (0.5, 0.5) would sit in
135
+ * the middle of every distribution and look exactly like a deliberate central tap.
136
+ */
137
+ export function localPoint(
138
+ rect: NormalizedRect | null,
139
+ nx: number | null | undefined,
140
+ ny: number | null | undefined,
141
+ ): { u: number; v: number } | null {
142
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
143
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
144
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
145
+ return {
146
+ u: (nx - rect.x) / rect.width,
147
+ v: (ny - rect.y) / rect.height,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Is the tap's normalized point inside the target's normalized rect?
153
+ *
154
+ * THE PREDICATE THE WHOLE MODULE EXISTS FOR. A heatmap that draws a marker labelled with a
155
+ * control's name is asserting this, and until now nothing checked it.
156
+ *
157
+ * Edges are INCLUSIVE. A tap on a button's exact border is a tap on the button — exclusive edges
158
+ * would report a sliver of every control as a miss, and at the resolution these coordinates carry
159
+ * that sliver is not a real distinction.
160
+ *
161
+ * Returns null, not false, when either input is missing. "The point is outside the control" and
162
+ * "we could not measure the control" are different findings with different fixes, and collapsing
163
+ * them into `false` would make an unmeasurable control look like a placement failure.
164
+ */
165
+ export function containsNormalizedPoint(
166
+ rect: NormalizedRect | null,
167
+ nx: number | null | undefined,
168
+ ny: number | null | undefined,
169
+ ): boolean | null {
170
+ if (!rect) return null;
171
+ if (typeof nx !== 'number' || typeof ny !== 'number') return null;
172
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null;
173
+ return (
174
+ nx >= rect.x && nx <= rect.x + rect.width && ny >= rect.y && ny <= rect.y + rect.height
175
+ );
176
+ }
@@ -9,29 +9,165 @@
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
 
21
61
  export interface TouchTarget {
22
- accessibilityLabel?: string;
62
+ /** Authored identifier. Safe to persist and to aggregate on. */
23
63
  testID?: string;
24
- text?: string;
64
+ /**
65
+ * Structural path of component names, tapped node first, e.g. `Pressable<OrderRow<CartScreen`.
66
+ * Derived from the code's shape, so it never contains user data.
67
+ */
68
+ path?: string;
69
+ /** Nearest named component. A code identifier, safe. */
25
70
  component?: string;
71
+ /**
72
+ * FREE TEXT — may contain user data. Populated ONLY when `includeText` is requested, and never
73
+ * returned by `describeTouchTarget`. Treat as unsafe to persist without an audit.
74
+ */
75
+ accessibilityLabel?: string;
76
+ /** FREE TEXT — the visible caption. Same warning as `accessibilityLabel`. */
77
+ text?: string;
78
+ /**
79
+ * RN-13 — the host node the IDENTITY was taken from, so a rect read off it describes the
80
+ * same element the tap was attributed to.
81
+ *
82
+ * This is the reason the node is captured during the identity walk rather than by a second,
83
+ * independent walk: two walks can disagree about which ancestor is "the control", and a rect
84
+ * belonging to a different element than the name is worse than no rect at all — it would make
85
+ * `heat ∈ target` answer confidently about the wrong box.
86
+ *
87
+ * Deliberately NOT serialisable and never persisted. Callers read a rectangle off it and
88
+ * discard it; nothing downstream should hold a React internal.
89
+ */
90
+ node?: unknown;
91
+ /**
92
+ * RN-3 — every ancestor host instance from the tapped node upward, INNERMOST FIRST.
93
+ *
94
+ * Collected on the identity walk rather than by a second traversal, for the same reason the
95
+ * rect's node is: two walks can disagree about the chain, and attributing a tap to the wrong
96
+ * scroller's offset places it by how far a DIFFERENT container had scrolled. Order is the
97
+ * contract — `nearestRecordedOffset` takes the first ancestor that has an offset, because that
98
+ * is the scroller the content under the finger actually moved with.
99
+ *
100
+ * Like `node`, these are React internals: read a native tag off them and discard.
101
+ */
102
+ ancestors?: unknown[];
103
+ /**
104
+ * RN-4 — the list row's own identity, for FREE.
105
+ *
106
+ * MEASURED BEFORE IT WAS DESIGNED, because the obvious fix was to make every app author a
107
+ * testID per row and that would have been a large, permanent burden on every integration. It is
108
+ * unnecessary. React Native's `VirtualizedList` builds each cell as
109
+ *
110
+ * const key = VirtualizedList._keyExtractor(item, ii, this.props); // line 797
111
+ * <CellRenderer cellKey={key} index={ii} key={key} ... /> // line 817
112
+ *
113
+ * so the app's OWN `keyExtractor` output — which every correct list already provides, because
114
+ * React requires keys — sits on the cell fiber as an ordinary prop. The walk that resolves
115
+ * identity already passes that fiber and simply never read it.
116
+ *
117
+ * `cellKey` is preferred over the fiber's `key` field: it is the same value, but reached
118
+ * through `memoizedProps` like every other prop this module reads, rather than through a React
119
+ * internal whose exposure is not part of any contract.
120
+ *
121
+ * WHY THIS MATTERS MORE THAN AN ORDINARY LABEL. A virtualised list REUSES native views: row 40
122
+ * scrolls into the view row 1 vacated. Without the cell key those two taps share a native
123
+ * identity and a screen position, so they merge — one hotspot on a row nobody in particular
124
+ * tapped. `itemIndex` is carried alongside precisely so the recycling is visible: the same index
125
+ * with a different key is a recycled slot, and that is a fact worth being able to prove rather
126
+ * than infer.
127
+ */
128
+ itemKey?: string;
129
+ /** The cell's position in the list at render time. See `itemKey` for why both are kept. */
130
+ itemIndex?: number;
131
+ }
132
+
133
+ /** Where a resolved identity came from — so a consumer can tell an authored id from a guess. */
134
+ export type TargetIdentitySource = 'testID' | 'path' | 'component' | 'none';
135
+
136
+ export interface ResolveTouchTargetOptions {
137
+ /** How far up the tree to look. */
138
+ maxDepth?: number;
139
+ /**
140
+ * Collect `accessibilityLabel` and the visible caption.
141
+ *
142
+ * OFF BY DEFAULT AND MEANT TO STAY THAT WAY. Turning it on means user-authored text may be
143
+ * carried out of the app, so it belongs to a team that has audited its own labels — not to a
144
+ * default. Even when on, the text is never used as the identity.
145
+ */
146
+ includeText?: boolean;
26
147
  }
27
148
 
28
149
  /** How far up the tree to look before giving up. Deep enough for a wrapped button, cheap enough to run on every tap. */
29
150
  const MAX_DEPTH = 12;
30
151
  /** Labels longer than this are truncated — a caption, not a paragraph. */
31
152
  const MAX_LEN = 60;
153
+ /**
154
+ * How many component names the structural path may hold.
155
+ *
156
+ * Four is enough to separate two same-named controls in different containers
157
+ * (`Pressable<OrderRow<CartScreen` vs `Pressable<SuggestionRow<CartScreen`) without letting the
158
+ * key drift every time someone adds a wrapper view higher up. A path that changes on unrelated
159
+ * refactors is a key that silently splits its own history.
160
+ */
161
+ const MAX_PATH_SEGMENTS = 4;
32
162
 
33
163
  interface FiberLike {
34
164
  memoizedProps?: Record<string, unknown> | null;
165
+ /**
166
+ * The host instance. On the New Architecture this is a ReactFabricHostComponent, which is
167
+ * the only thing in reach that can report a rectangle SYNCHRONOUSLY — see targetGeometry.ts
168
+ * for why an asynchronous measurement is unusable for a per-tap rect.
169
+ */
170
+ stateNode?: unknown;
35
171
  elementType?: unknown;
36
172
  type?: unknown;
37
173
  return?: FiberLike | null;
@@ -56,7 +192,7 @@ function componentName(f: FiberLike): string | undefined {
56
192
  return n.slice(0, MAX_LEN);
57
193
  }
58
194
 
59
- /** First literal string child, e.g. <Text>Buy now</Text>. */
195
+ /** First literal string child, e.g. <Text>Buy now</Text>. FREE TEXT — see the header. */
60
196
  function textChild(props: Record<string, unknown> | null | undefined): string | undefined {
61
197
  if (!props) return undefined;
62
198
  const c = props.children;
@@ -76,10 +212,11 @@ function fiberOf(event: unknown): FiberLike | null {
76
212
  const e = event as Record<string, unknown>;
77
213
  const direct = e._targetInst;
78
214
  if (direct && typeof direct === 'object') return direct as FiberLike;
79
- // Newer RN exposes the instance handle on the target node instead.
80
- const target = e.target as Record<string, unknown> | undefined;
81
- if (target && typeof target === 'object') {
82
- const handle = target._internalFiberInstanceHandleDEV ?? target._internalInstanceHandle;
215
+ const target = (e.target ?? (e.nativeEvent as Record<string, unknown> | undefined)?.target) as
216
+ | Record<string, unknown>
217
+ | undefined;
218
+ if (target) {
219
+ const handle = target._internalInstanceHandle ?? target._targetInst;
83
220
  if (handle && typeof handle === 'object') return handle as FiberLike;
84
221
  }
85
222
  return null;
@@ -88,41 +225,115 @@ function fiberOf(event: unknown): FiberLike | null {
88
225
  /**
89
226
  * Walk up from the touched fiber collecting the best identity available.
90
227
  *
91
- * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf only had text,
92
- * but stops as soon as BOTH an accessibilityLabel and a testID are known — nothing above can improve on
93
- * that pair.
228
+ * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf had none,
229
+ * and stops as soon as a `testID` is known AND the path is full — nothing above can improve on that.
94
230
  */
95
- export function resolveTouchTarget(event: unknown, maxDepth = MAX_DEPTH): TouchTarget | null {
231
+ export function resolveTouchTarget(
232
+ event: unknown,
233
+ optsOrMaxDepth: ResolveTouchTargetOptions | number = {},
234
+ ): TouchTarget | null {
235
+ /* Number form kept for the previous signature `resolveTouchTarget(event, maxDepth)`. */
236
+ const opts: ResolveTouchTargetOptions =
237
+ typeof optsOrMaxDepth === 'number' ? { maxDepth: optsOrMaxDepth } : optsOrMaxDepth;
238
+ const maxDepth = opts.maxDepth ?? MAX_DEPTH;
239
+
96
240
  let f = fiberOf(event);
97
241
  if (!f) return null;
98
242
  const out: TouchTarget = {};
243
+ const segments: string[] = [];
244
+ const ancestors: unknown[] = [];
99
245
  let depth = 0;
100
246
  try {
101
247
  while (f && depth < maxDepth) {
102
248
  const props = f.memoizedProps;
103
249
  if (props) {
104
- out.accessibilityLabel ??= str(props.accessibilityLabel);
250
+ const beforeTestId = out.testID;
105
251
  out.testID ??= str(props.testID);
106
- out.text ??= textChild(props);
252
+ /* The node is pinned to the fiber that supplied the testID — the CONTROL, not the
253
+ deepest node hit. A Text inside a Pressable is a tap on the Pressable, so the rect
254
+ has to be the Pressable's or the containment test would be run against the caption. */
255
+ if (!beforeTestId && out.testID) out.node = f.stateNode;
256
+ /* The nearest enclosing cell wins: nested lists mean a row can sit inside another
257
+ row, and the innermost is the one the finger is actually in. `??=` keeps the first
258
+ seen while walking outward, which is the innermost. */
259
+ if (typeof props.cellKey === 'string' && props.cellKey) {
260
+ out.itemKey ??= str(props.cellKey);
261
+ if (typeof props.index === 'number' && Number.isFinite(props.index)) {
262
+ out.itemIndex ??= props.index;
263
+ }
264
+ }
265
+ if (opts.includeText) {
266
+ out.accessibilityLabel ??= str(props.accessibilityLabel);
267
+ out.text ??= textChild(props);
268
+ }
269
+ }
270
+ /* Host instances only — a composite fiber has no native tag, so keeping them would pad
271
+ the chain with entries no scroller could ever match. */
272
+ if (f.stateNode) ancestors.push(f.stateNode);
273
+ const name = componentName(f);
274
+ if (name) {
275
+ out.component ??= name;
276
+ /* Consecutive duplicates collapse: a component that renders itself through a memo or a
277
+ forwardRef appears twice in the fiber chain and would otherwise pad the path with a
278
+ repetition that carries no information. */
279
+ if (segments[segments.length - 1] !== name && segments.length < MAX_PATH_SEGMENTS) {
280
+ segments.push(name);
281
+ }
107
282
  }
108
- out.component ??= componentName(f);
109
- if (out.accessibilityLabel && out.testID) break;
283
+ if (out.testID && segments.length >= MAX_PATH_SEGMENTS) break;
110
284
  f = f.return ?? null;
111
285
  depth++;
112
286
  }
113
287
  } catch {
114
288
  /* React internals shifted under us — return whatever was gathered so far */
115
289
  }
116
- return out.accessibilityLabel || out.testID || out.text || out.component ? out : null;
290
+
291
+ /* Tapped node first, so the most specific segment leads and a truncated path is still anchored
292
+ to the thing actually touched. */
293
+ if (segments.length) out.path = segments.join('<');
294
+
295
+ /* No authored testID anywhere, so identity came from the structural path. Anchor the rect to
296
+ the node that was actually touched — imperfect (it may be the caption rather than the
297
+ control) but honest, and better than no geometry for the majority of apps, which author no
298
+ testIDs. `targetSource` records which case this was, so a consumer can weight it. */
299
+ if (!out.node) out.node = fiberOf(event)?.stateNode;
300
+
301
+ if (ancestors.length) out.ancestors = ancestors;
302
+
303
+ return out.testID || out.path || out.component || out.accessibilityLabel || out.text || out.itemKey
304
+ ? out
305
+ : null;
117
306
  }
118
307
 
119
308
  /**
120
- * One short human label for the tapped thing, or undefined when nothing identified it.
309
+ * One short, STABLE, non-identifying label for the tapped thing.
121
310
  *
122
- * This is what ends up grouping the Events explorer, so it must be STABLE: a11y label and testID are
123
- * authored and rarely change, whereas visible text moves with copy edits hence the ordering.
311
+ * This is what ends up grouping the Events explorer and what gets persisted as the interaction's
312
+ * target, so it must satisfy two constraints at once: stable across copy edits, and free of user
313
+ * data. Both rule out visible text, which is why the free-text fields are not consulted here even
314
+ * when they have been resolved — see the RN-2 note in the header.
124
315
  */
125
316
  export function describeTouchTarget(t: TouchTarget | null): string | undefined {
126
317
  if (!t) return undefined;
127
- return t.accessibilityLabel || t.testID || t.text || t.component;
318
+ return t.testID || t.path || t.component;
319
+ }
320
+
321
+ /**
322
+ * The identity plus WHERE IT CAME FROM.
323
+ *
324
+ * A consumer cannot treat an authored `testID` and a structural `path` as equally trustworthy: the
325
+ * first is a promise from the app's authors that this control is this control, the second is an
326
+ * inference that breaks when the tree is refactored. Returning the source lets the accounting lane
327
+ * say WHY a tap could not be attributed, instead of reporting a missing target and leaving the
328
+ * reader to guess whether the control had no identity or the walk failed.
329
+ */
330
+ export function targetIdentity(t: TouchTarget | null): {
331
+ id?: string;
332
+ source: TargetIdentitySource;
333
+ } {
334
+ if (!t) return { source: 'none' };
335
+ if (t.testID) return { id: t.testID, source: 'testID' };
336
+ if (t.path) return { id: t.path, source: 'path' };
337
+ if (t.component) return { id: t.component, source: 'component' };
338
+ return { source: 'none' };
128
339
  }
@@ -50,7 +50,10 @@ const clean = (s: string): string =>
50
50
  export function setUiState(name: string, value: string): void {
51
51
  const n = clean(name);
52
52
  const v = clean(value);
53
- if (n && v) declared.set(n, v);
53
+ if (!n || !v) return;
54
+ if (declared.get(n) === v) return;
55
+ declared.set(n, v);
56
+ notifyChanged();
54
57
  }
55
58
 
56
59
  /**
@@ -62,8 +65,10 @@ export function setUiState(name: string, value: string): void {
62
65
  * screen.
63
66
  */
64
67
  export function clearUiState(name?: string): void {
68
+ const before = declared.size;
65
69
  if (name === undefined) declared.clear();
66
70
  else declared.delete(clean(name));
71
+ if (declared.size !== before) notifyChanged();
67
72
  }
68
73
 
69
74
  /**
@@ -87,3 +92,77 @@ export function uiStateSignature(): string | undefined {
87
92
  }
88
93
  return signature || undefined;
89
94
  }
95
+
96
+ /**
97
+ * RN-11 — TELL SOMEONE THE STATE CHANGED, so a frame can exist for it.
98
+ *
99
+ * THE GAP. Declaring a state used to mutate a Map and nothing else. Nothing captured a frame, so
100
+ * the visual for a declared state existed only if a screenshot happened to be taken for some other
101
+ * reason — a navigation, a timer, or the interaction capture that fires AFTER the touch and
102
+ * photographs the outcome. A state could therefore be recorded as a fact with no picture of it
103
+ * anywhere, or with a picture of the state that replaced it.
104
+ *
105
+ * The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER. None of them is "the
106
+ * UI changed shape", which is exactly the moment a heatmap needs a frame.
107
+ *
108
+ * FIRING ON A REAL CHANGE ONLY, AND WHY THAT IS THE SDK'S JOB. The advice above says to call
109
+ * `setUiState` when the state changes rather than on every render — but that is advice to the
110
+ * host, not a guarantee. A component that declares its state inside render is ordinary React, and
111
+ * trusting the caller would mean a screenshot per render: a performance disaster in exactly the
112
+ * apps that instrument most carefully. So a redundant set — same dimension, same value — is now a
113
+ * no-op, and the change test lives here where it cannot be got wrong by a host.
114
+ *
115
+ * COALESCED, because a transition usually declares several dimensions in one tick ("drawer:open"
116
+ * and "overlay:visible" belong to one visual change). Notifying per call would capture the
117
+ * intermediate shapes, which are states the user never saw.
118
+ *
119
+ * The listener is deliberately NOT given the signature. A subscriber that wants it calls
120
+ * `uiStateSignature()` at the moment it acts, which is the value that will actually be attached
121
+ * to the next tap; handing over a snapshot invites acting on a state that has already moved on.
122
+ */
123
+ export type UiStateChangeListener = () => void;
124
+
125
+ const listeners = new Set<UiStateChangeListener>();
126
+ let pending: ReturnType<typeof setTimeout> | null = null;
127
+
128
+ /**
129
+ * Subscribe to declared-state changes. Returns an unsubscribe.
130
+ *
131
+ * Registration is idempotent per function reference, so a component that re-subscribes on every
132
+ * render does not accumulate listeners — a leak that would only show up in long sessions.
133
+ */
134
+ export function onUiStateChange(listener: UiStateChangeListener): () => void {
135
+ listeners.add(listener);
136
+ return () => {
137
+ listeners.delete(listener);
138
+ };
139
+ }
140
+
141
+ /** Drop every listener and any pending notification. For teardown between sessions. */
142
+ export function resetUiStateListeners(): void {
143
+ listeners.clear();
144
+ if (pending !== null) {
145
+ clearTimeout(pending);
146
+ pending = null;
147
+ }
148
+ }
149
+
150
+ /** For assertions that a re-subscribing caller has not leaked. */
151
+ export function uiStateListenerCount(): number {
152
+ return listeners.size;
153
+ }
154
+
155
+ function notifyChanged(): void {
156
+ if (pending !== null) return;
157
+ pending = setTimeout(() => {
158
+ pending = null;
159
+ for (const listener of listeners) {
160
+ try {
161
+ listener();
162
+ } catch {
163
+ /* One bad subscriber must not stop the others, and must never surface to the host: a
164
+ screenshot is not worth an app crash. */
165
+ }
166
+ }
167
+ }, 0);
168
+ }