@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
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.describeTouchTarget = describeTouchTarget;
7
7
  exports.resolveTouchTarget = resolveTouchTarget;
8
+ exports.targetIdentity = targetIdentity;
8
9
  /**
9
10
  * Resolve WHAT was tapped, from a React Native touch event.
10
11
  *
@@ -16,19 +17,70 @@ exports.resolveTouchTarget = resolveTouchTarget;
16
17
  * WHY THE FIBER. There is no DOM. The only place a tapped view's identity exists in JS is the React
17
18
  * fiber hanging off the touch event (`_targetInst`), which carries `memoizedProps` — where
18
19
  * `accessibilityLabel` and `testID` live. This is a React internal, so every access is defensive and
19
- * the whole thing degrades to `null` rather than throwing: a analytics label is never worth a crash.
20
+ * the whole thing degrades to `null` rather than throwing: an analytics label is never worth a crash.
20
21
  *
21
- * IDENTITY PREFERENCE, strongest first:
22
- * 1. `accessibilityLabel`authored to describe the control, and already required for a11y.
23
- * 2. `testID` — authored to identify the control, stable across copy changes.
24
- * 3. a literal string child — the button's visible caption.
25
- * 4. the nearest named component — coarse, but far better than nothing.
22
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
23
+ * RN-2THE IDENTITY LADDER USED TO CAPTURE USER TEXT, AND THAT IS A PRIVACY DEFECT, NOT A
24
+ * TRADE-OFF.
25
+ *
26
+ * The previous preference order was:
27
+ *
28
+ * 1. accessibilityLabel 2. testID 3. a literal string child 4. nearest component
29
+ *
30
+ * Ranks 1 and 3 are FREE TEXT authored for humans, and in a real app they routinely contain user
31
+ * data. An accessible order row is labelled "Order 1042 for mohamed@scalebun.com". A pay button
32
+ * reads "Pay EGP 1,442". A contact row's caption is a person's name. Rank 1 was preferred over
33
+ * `testID`, so the leakiest source won whenever it existed.
34
+ *
35
+ * Two separate harms, and the second is easy to miss:
36
+ *
37
+ * PRIVACY. `describeTouchTarget`'s output is persisted as the interaction's `target`. So names,
38
+ * email addresses and amounts were being written into an analytics store, under a field nobody
39
+ * would think to audit for PII. The same product already found 27 rows in `replay_events`
40
+ * carrying email addresses through a different route; this was a second one.
41
+ *
42
+ * CORRECTNESS. `target` is an AGGREGATION KEY. Keyed on text that varies per user, a single
43
+ * button produces one bucket per person — so the grouping this feature exists to provide is
44
+ * destroyed precisely in the screens that matter most. A leaky key is also a useless key.
45
+ *
46
+ * SO THE LADDER IS NOW DERIVED FROM CODE, NEVER FROM CONTENT:
47
+ *
48
+ * 1. `testID` — authored to identify, stable across copy edits, and by convention never
49
+ * user data. The strongest signal and now the first.
50
+ * 2. `path` — the component-name chain from the tapped node upward, e.g.
51
+ * `CartScreen>OrderRow>Pressable`. Structural: it changes when the code
52
+ * changes, not when the data does. This is what makes the ladder useful in
53
+ * the overwhelming majority of apps, which have no testIDs — without it,
54
+ * removing free text would have meant removing identity.
55
+ * 3. `component` — the nearest named component alone. Coarse, but a code identifier.
56
+ *
57
+ * Free text is still RESOLVABLE, because a team that has audited its own labels may legitimately
58
+ * want them, but it is opt-in per call (`includeText`) and it is NEVER what
59
+ * `describeTouchTarget` returns. Default-off matters more than the option: a default that leaks
60
+ * is a leak, whatever the documentation says.
61
+ *
62
+ * WHY THIS SHIPS WITH RN-1 (persistence) RATHER THAN AFTER IT. RN-1 makes the resolved target
63
+ * actually reach the backend. Landing that first would persist the leaky ladder and create a
64
+ * historical PII problem to clean up; landing the sanitised ladder first and persistence second
65
+ * leaves a window where the feature looks broken. They are one change.
66
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
26
67
  */
27
68
 
69
+ /** Where a resolved identity came from — so a consumer can tell an authored id from a guess. */
70
+
28
71
  /** How far up the tree to look before giving up. Deep enough for a wrapped button, cheap enough to run on every tap. */
29
72
  const MAX_DEPTH = 12;
30
73
  /** Labels longer than this are truncated — a caption, not a paragraph. */
31
74
  const MAX_LEN = 60;
75
+ /**
76
+ * How many component names the structural path may hold.
77
+ *
78
+ * Four is enough to separate two same-named controls in different containers
79
+ * (`Pressable<OrderRow<CartScreen` vs `Pressable<SuggestionRow<CartScreen`) without letting the
80
+ * key drift every time someone adds a wrapper view higher up. A path that changes on unrelated
81
+ * refactors is a key that silently splits its own history.
82
+ */
83
+ const MAX_PATH_SEGMENTS = 4;
32
84
  function str(v) {
33
85
  if (typeof v !== 'string') return undefined;
34
86
  const s = v.trim();
@@ -48,7 +100,7 @@ function componentName(f) {
48
100
  return n.slice(0, MAX_LEN);
49
101
  }
50
102
 
51
- /** First literal string child, e.g. <Text>Buy now</Text>. */
103
+ /** First literal string child, e.g. <Text>Buy now</Text>. FREE TEXT — see the header. */
52
104
  function textChild(props) {
53
105
  if (!props) return undefined;
54
106
  const c = props.children;
@@ -68,10 +120,9 @@ function fiberOf(event) {
68
120
  const e = event;
69
121
  const direct = e._targetInst;
70
122
  if (direct && typeof direct === 'object') return direct;
71
- // Newer RN exposes the instance handle on the target node instead.
72
- const target = e.target;
73
- if (target && typeof target === 'object') {
74
- const handle = target._internalFiberInstanceHandleDEV ?? target._internalInstanceHandle;
123
+ const target = e.target ?? e.nativeEvent?.target;
124
+ if (target) {
125
+ const handle = target._internalInstanceHandle ?? target._targetInst;
75
126
  if (handle && typeof handle === 'object') return handle;
76
127
  }
77
128
  return null;
@@ -80,42 +131,119 @@ function fiberOf(event) {
80
131
  /**
81
132
  * Walk up from the touched fiber collecting the best identity available.
82
133
  *
83
- * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf only had text,
84
- * but stops as soon as BOTH an accessibilityLabel and a testID are known — nothing above can improve on
85
- * that pair.
134
+ * Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf had none,
135
+ * and stops as soon as a `testID` is known AND the path is full — nothing above can improve on that.
86
136
  */
87
- function resolveTouchTarget(event, maxDepth = MAX_DEPTH) {
137
+ function resolveTouchTarget(event, optsOrMaxDepth = {}) {
138
+ /* Number form kept for the previous signature `resolveTouchTarget(event, maxDepth)`. */
139
+ const opts = typeof optsOrMaxDepth === 'number' ? {
140
+ maxDepth: optsOrMaxDepth
141
+ } : optsOrMaxDepth;
142
+ const maxDepth = opts.maxDepth ?? MAX_DEPTH;
88
143
  let f = fiberOf(event);
89
144
  if (!f) return null;
90
145
  const out = {};
146
+ const segments = [];
147
+ const ancestors = [];
91
148
  let depth = 0;
92
149
  try {
93
150
  while (f && depth < maxDepth) {
94
151
  const props = f.memoizedProps;
95
152
  if (props) {
96
- out.accessibilityLabel ??= str(props.accessibilityLabel);
153
+ const beforeTestId = out.testID;
97
154
  out.testID ??= str(props.testID);
98
- out.text ??= textChild(props);
155
+ /* The node is pinned to the fiber that supplied the testID — the CONTROL, not the
156
+ deepest node hit. A Text inside a Pressable is a tap on the Pressable, so the rect
157
+ has to be the Pressable's or the containment test would be run against the caption. */
158
+ if (!beforeTestId && out.testID) out.node = f.stateNode;
159
+ /* The nearest enclosing cell wins: nested lists mean a row can sit inside another
160
+ row, and the innermost is the one the finger is actually in. `??=` keeps the first
161
+ seen while walking outward, which is the innermost. */
162
+ if (typeof props.cellKey === 'string' && props.cellKey) {
163
+ out.itemKey ??= str(props.cellKey);
164
+ if (typeof props.index === 'number' && Number.isFinite(props.index)) {
165
+ out.itemIndex ??= props.index;
166
+ }
167
+ }
168
+ if (opts.includeText) {
169
+ out.accessibilityLabel ??= str(props.accessibilityLabel);
170
+ out.text ??= textChild(props);
171
+ }
99
172
  }
100
- out.component ??= componentName(f);
101
- if (out.accessibilityLabel && out.testID) break;
173
+ /* Host instances only — a composite fiber has no native tag, so keeping them would pad
174
+ the chain with entries no scroller could ever match. */
175
+ if (f.stateNode) ancestors.push(f.stateNode);
176
+ const name = componentName(f);
177
+ if (name) {
178
+ out.component ??= name;
179
+ /* Consecutive duplicates collapse: a component that renders itself through a memo or a
180
+ forwardRef appears twice in the fiber chain and would otherwise pad the path with a
181
+ repetition that carries no information. */
182
+ if (segments[segments.length - 1] !== name && segments.length < MAX_PATH_SEGMENTS) {
183
+ segments.push(name);
184
+ }
185
+ }
186
+ if (out.testID && segments.length >= MAX_PATH_SEGMENTS) break;
102
187
  f = f.return ?? null;
103
188
  depth++;
104
189
  }
105
190
  } catch {
106
191
  /* React internals shifted under us — return whatever was gathered so far */
107
192
  }
108
- return out.accessibilityLabel || out.testID || out.text || out.component ? out : null;
193
+
194
+ /* Tapped node first, so the most specific segment leads and a truncated path is still anchored
195
+ to the thing actually touched. */
196
+ if (segments.length) out.path = segments.join('<');
197
+
198
+ /* No authored testID anywhere, so identity came from the structural path. Anchor the rect to
199
+ the node that was actually touched — imperfect (it may be the caption rather than the
200
+ control) but honest, and better than no geometry for the majority of apps, which author no
201
+ testIDs. `targetSource` records which case this was, so a consumer can weight it. */
202
+ if (!out.node) out.node = fiberOf(event)?.stateNode;
203
+ if (ancestors.length) out.ancestors = ancestors;
204
+ return out.testID || out.path || out.component || out.accessibilityLabel || out.text || out.itemKey ? out : null;
109
205
  }
110
206
 
111
207
  /**
112
- * One short human label for the tapped thing, or undefined when nothing identified it.
208
+ * One short, STABLE, non-identifying label for the tapped thing.
113
209
  *
114
- * This is what ends up grouping the Events explorer, so it must be STABLE: a11y label and testID are
115
- * authored and rarely change, whereas visible text moves with copy edits hence the ordering.
210
+ * This is what ends up grouping the Events explorer and what gets persisted as the interaction's
211
+ * target, so it must satisfy two constraints at once: stable across copy edits, and free of user
212
+ * data. Both rule out visible text, which is why the free-text fields are not consulted here even
213
+ * when they have been resolved — see the RN-2 note in the header.
116
214
  */
117
215
  function describeTouchTarget(t) {
118
216
  if (!t) return undefined;
119
- return t.accessibilityLabel || t.testID || t.text || t.component;
217
+ return t.testID || t.path || t.component;
218
+ }
219
+
220
+ /**
221
+ * The identity plus WHERE IT CAME FROM.
222
+ *
223
+ * A consumer cannot treat an authored `testID` and a structural `path` as equally trustworthy: the
224
+ * first is a promise from the app's authors that this control is this control, the second is an
225
+ * inference that breaks when the tree is refactored. Returning the source lets the accounting lane
226
+ * say WHY a tap could not be attributed, instead of reporting a missing target and leaving the
227
+ * reader to guess whether the control had no identity or the walk failed.
228
+ */
229
+ function targetIdentity(t) {
230
+ if (!t) return {
231
+ source: 'none'
232
+ };
233
+ if (t.testID) return {
234
+ id: t.testID,
235
+ source: 'testID'
236
+ };
237
+ if (t.path) return {
238
+ id: t.path,
239
+ source: 'path'
240
+ };
241
+ if (t.component) return {
242
+ id: t.component,
243
+ source: 'component'
244
+ };
245
+ return {
246
+ source: 'none'
247
+ };
120
248
  }
121
249
  //# sourceMappingURL=touchTarget.js.map
@@ -4,7 +4,10 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.clearUiState = clearUiState;
7
+ exports.onUiStateChange = onUiStateChange;
8
+ exports.resetUiStateListeners = resetUiStateListeners;
7
9
  exports.setUiState = setUiState;
10
+ exports.uiStateListenerCount = uiStateListenerCount;
8
11
  exports.uiStateSignature = uiStateSignature;
9
12
  /**
10
13
  * UI STATE AT INTERACTION TIME — React Native.
@@ -57,7 +60,10 @@ const clean = s => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(
57
60
  function setUiState(name, value) {
58
61
  const n = clean(name);
59
62
  const v = clean(value);
60
- if (n && v) declared.set(n, v);
63
+ if (!n || !v) return;
64
+ if (declared.get(n) === v) return;
65
+ declared.set(n, v);
66
+ notifyChanged();
61
67
  }
62
68
 
63
69
  /**
@@ -69,7 +75,9 @@ function setUiState(name, value) {
69
75
  * screen.
70
76
  */
71
77
  function clearUiState(name) {
78
+ const before = declared.size;
72
79
  if (name === undefined) declared.clear();else declared.delete(clean(name));
80
+ if (declared.size !== before) notifyChanged();
73
81
  }
74
82
 
75
83
  /**
@@ -91,4 +99,76 @@ function uiStateSignature() {
91
99
  }
92
100
  return signature || undefined;
93
101
  }
102
+
103
+ /**
104
+ * RN-11 — TELL SOMEONE THE STATE CHANGED, so a frame can exist for it.
105
+ *
106
+ * THE GAP. Declaring a state used to mutate a Map and nothing else. Nothing captured a frame, so
107
+ * the visual for a declared state existed only if a screenshot happened to be taken for some other
108
+ * reason — a navigation, a timer, or the interaction capture that fires AFTER the touch and
109
+ * photographs the outcome. A state could therefore be recorded as a fact with no picture of it
110
+ * anywhere, or with a picture of the state that replaced it.
111
+ *
112
+ * The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER. None of them is "the
113
+ * UI changed shape", which is exactly the moment a heatmap needs a frame.
114
+ *
115
+ * FIRING ON A REAL CHANGE ONLY, AND WHY THAT IS THE SDK'S JOB. The advice above says to call
116
+ * `setUiState` when the state changes rather than on every render — but that is advice to the
117
+ * host, not a guarantee. A component that declares its state inside render is ordinary React, and
118
+ * trusting the caller would mean a screenshot per render: a performance disaster in exactly the
119
+ * apps that instrument most carefully. So a redundant set — same dimension, same value — is now a
120
+ * no-op, and the change test lives here where it cannot be got wrong by a host.
121
+ *
122
+ * COALESCED, because a transition usually declares several dimensions in one tick ("drawer:open"
123
+ * and "overlay:visible" belong to one visual change). Notifying per call would capture the
124
+ * intermediate shapes, which are states the user never saw.
125
+ *
126
+ * The listener is deliberately NOT given the signature. A subscriber that wants it calls
127
+ * `uiStateSignature()` at the moment it acts, which is the value that will actually be attached
128
+ * to the next tap; handing over a snapshot invites acting on a state that has already moved on.
129
+ */
130
+
131
+ const listeners = new Set();
132
+ let pending = null;
133
+
134
+ /**
135
+ * Subscribe to declared-state changes. Returns an unsubscribe.
136
+ *
137
+ * Registration is idempotent per function reference, so a component that re-subscribes on every
138
+ * render does not accumulate listeners — a leak that would only show up in long sessions.
139
+ */
140
+ function onUiStateChange(listener) {
141
+ listeners.add(listener);
142
+ return () => {
143
+ listeners.delete(listener);
144
+ };
145
+ }
146
+
147
+ /** Drop every listener and any pending notification. For teardown between sessions. */
148
+ function resetUiStateListeners() {
149
+ listeners.clear();
150
+ if (pending !== null) {
151
+ clearTimeout(pending);
152
+ pending = null;
153
+ }
154
+ }
155
+
156
+ /** For assertions that a re-subscribing caller has not leaked. */
157
+ function uiStateListenerCount() {
158
+ return listeners.size;
159
+ }
160
+ function notifyChanged() {
161
+ if (pending !== null) return;
162
+ pending = setTimeout(() => {
163
+ pending = null;
164
+ for (const listener of listeners) {
165
+ try {
166
+ listener();
167
+ } catch {
168
+ /* One bad subscriber must not stop the others, and must never surface to the host: a
169
+ screenshot is not worth an app crash. */
170
+ }
171
+ }
172
+ }, 0);
173
+ }
94
174
  //# sourceMappingURL=uiState.js.map
@@ -41,6 +41,15 @@ class AutoScreenDetector {
41
41
  _manualScreen = null;
42
42
  _manualScreenTs = 0;
43
43
  _autoScreen = null;
44
+ /**
45
+ * When `_autoScreen` was last written.
46
+ *
47
+ * Needed so `getCurrentScreen()` can prefer whichever source spoke MOST RECENTLY.
48
+ * Without a timestamp the only comparison available was "is the manual value still
49
+ * fresh", which is a different question and gave the wrong answer — see the note on
50
+ * `_currentScreen()`.
51
+ */
52
+ _autoScreenTs = 0;
44
53
  /** `name#key` of the last auto-detected screen VISIT — see _extractActiveVisit. */
45
54
  _autoVisitSig = null;
46
55
  _navigationRef = null;
@@ -115,6 +124,7 @@ class AutoScreenDetector {
115
124
  if (this.isManualScreenActive()) return;
116
125
  if (name !== this._autoScreen) {
117
126
  this._autoScreen = name;
127
+ this._autoScreenTs = Date.now();
118
128
  this._autoVisitSig = name; // native lifecycle carries no route key — name-only visit
119
129
  this._emitScreenChange(name, 'heuristic');
120
130
  }
@@ -122,8 +132,19 @@ class AutoScreenDetector {
122
132
  }
123
133
 
124
134
  /**
125
- * Check if a manual screen is currently active.
126
- * Used by auto-detection to yield to manual overrides.
135
+ * Should a WRITER defer to the manual screen right now?
136
+ *
137
+ * THIS IS A PRECEDENCE GATE, NOT A VALIDITY TEST, and conflating the two was a real
138
+ * bug. Its job is what the cooldown constant says: for a short window after a manual
139
+ * beacon fires, an auto source must not overwrite it — the poller runs every 1500 ms
140
+ * and would otherwise clobber a fresh declaration with whatever it inferred.
141
+ *
142
+ * `getCurrentScreen()` used to reuse this as "is the manual screen still the current
143
+ * screen", which made a declared screen READABLE for only 2000 ms. Nothing called
144
+ * `setManualScreen` in production, so the effect was latent — but the moment the
145
+ * `<ScaleBunScreen>` beacon was wired to it (RN-8), every tap more than two seconds
146
+ * after a screen mounted would have lost its name, which is very nearly every tap. A
147
+ * fix that introduced that would have been worse than the gap it closed.
127
148
  */
128
149
  isManualScreenActive() {
129
150
  if (!this._manualScreen) return false;
@@ -183,6 +204,7 @@ class AutoScreenDetector {
183
204
  const visit = this._extractActiveVisit(state);
184
205
  if (visit && this._visitSig(visit) !== this._autoVisitSig) {
185
206
  this._autoScreen = visit.name;
207
+ this._autoScreenTs = Date.now();
186
208
  this._autoVisitSig = this._visitSig(visit);
187
209
  this._emitScreenChange(visit.name, 'navRef');
188
210
  }
@@ -201,6 +223,7 @@ class AutoScreenDetector {
201
223
  const visit = this._extractActiveVisit(state);
202
224
  if (visit) {
203
225
  this._autoScreen = visit.name;
226
+ this._autoScreenTs = Date.now();
204
227
  this._autoVisitSig = this._visitSig(visit);
205
228
  this._emitScreenChange(visit.name, 'navRef');
206
229
  }
@@ -244,6 +267,7 @@ class AutoScreenDetector {
244
267
  const visit = this._extractActiveVisit(state);
245
268
  if (visit && this._visitSig(visit) !== this._autoVisitSig) {
246
269
  this._autoScreen = visit.name;
270
+ this._autoScreenTs = Date.now();
247
271
  this._autoVisitSig = this._visitSig(visit);
248
272
  this._emitScreenChange(visit.name, 'heuristic');
249
273
  }
@@ -500,16 +524,61 @@ class AutoScreenDetector {
500
524
  this._listeners = [];
501
525
  this._manualScreen = null;
502
526
  this._autoScreen = null;
527
+ this._autoScreenTs = 0;
503
528
  this._navigationRef = null;
504
529
  _instance = null;
505
530
  }
506
531
 
507
532
  /** Get the current best-known screen name */
508
533
  getCurrentScreen() {
509
- if (this._manualScreen && this.isManualScreenActive()) {
510
- return this._manualScreen;
534
+ return this._currentScreen().name;
535
+ }
536
+
537
+ /**
538
+ * WHERE the current screen name came from, or 'none'.
539
+ *
540
+ * Recorded on every interaction (RN-8) so low screen coverage is DIAGNOSABLE rather
541
+ * than merely visible. Production shows 13.8% of Android interactions carrying a
542
+ * screen and 0.5% on iOS, and those numbers cannot today distinguish three different
543
+ * causes: the app never instrumented anything, the app instrumented manually and the
544
+ * value never reached interactions, or auto-detection ran and failed. Those have
545
+ * different owners and different fixes, and guessing between them is how a P0 stays
546
+ * open.
547
+ */
548
+ getCurrentScreenSource() {
549
+ return this._currentScreen().source;
550
+ }
551
+
552
+ /**
553
+ * LAST WRITER WINS between the manual beacon and auto-detection.
554
+ *
555
+ * A screen declared by `<ScaleBunScreen name>` stays current until something else
556
+ * declares a different one — that is what mounting a screen means. It does not
557
+ * expire, which is the correction here: the previous rule returned the manual name
558
+ * only while it was under 2000 ms old and then silently fell back to the auto value,
559
+ * which in a manually-instrumented app is `null`.
560
+ *
561
+ * Comparing timestamps rather than hard-coding a priority also handles the real
562
+ * interleaving: an app can use React Navigation for most screens AND drop a manual
563
+ * beacon on one modal, and whichever spoke last is the truth.
564
+ */
565
+ _currentScreen() {
566
+ const manual = this._manualScreen;
567
+ const auto = this._autoScreen;
568
+ if (manual && (!auto || this._manualScreenTs >= this._autoScreenTs)) {
569
+ return {
570
+ name: manual,
571
+ source: 'manual'
572
+ };
511
573
  }
512
- return this._autoScreen;
574
+ if (auto) return {
575
+ name: auto,
576
+ source: 'navigation'
577
+ };
578
+ return {
579
+ name: null,
580
+ source: 'none'
581
+ };
513
582
  }
514
583
 
515
584
  // ─── Internal ───────────────────────────────────────────────────────