@scalebun/react-native 1.13.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -22,6 +22,16 @@ export interface Spec extends TurboModule {
|
|
|
22
22
|
* the bundle URL at launch.
|
|
23
23
|
*/
|
|
24
24
|
getActiveBundle(): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* JSON array (string) of the trusted key IDs embedded in the app package, e.g.
|
|
27
|
+
* `["production-2026-01"]`. Reported on OTA check-in for SDK trust coverage
|
|
28
|
+
* (CICDPAGE §12) so the dashboard can measure key-rotation adoption. This is
|
|
29
|
+
* telemetry ONLY — the server never trusts a key because a device claims it.
|
|
30
|
+
* Empty array when no keys are embedded.
|
|
31
|
+
*/
|
|
32
|
+
getTrustedKeyIds(): string;
|
|
33
|
+
/** JSON array of root/jailbreak indicator strings; [] when none found. */
|
|
34
|
+
getDeviceIntegrity(): string;
|
|
25
35
|
/**
|
|
26
36
|
* Returns JSON with current slot state:
|
|
27
37
|
* { current: { bundleId, version, sha256, installedAt } | null,
|
|
@@ -77,28 +87,30 @@ export interface Spec extends TurboModule {
|
|
|
77
87
|
*/
|
|
78
88
|
restartApp(): void;
|
|
79
89
|
/**
|
|
80
|
-
* Verify a
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* provides ed25519 (iOS 13+ CryptoKit everywhere; Android API 33+).
|
|
90
|
+
* Verify a SB-OTA-RSA-SHA256-V1 release envelope in NATIVE code, with no side
|
|
91
|
+
* effects. `envelopeJson` is the JSON `{ protocol, keyId, signedPayload,
|
|
92
|
+
* signature, downloadUrl? }` from the check response. Verification uses the
|
|
93
|
+
* trusted RSA public keys EMBEDDED in the app package (never a key from JS or
|
|
94
|
+
* the server) and platform `SHA256withRSA` (present on every supported API
|
|
95
|
+
* level — no API-33 floor).
|
|
87
96
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
* Resolves a verdict JSON string: `{ ok: boolean, code?: string, message?,
|
|
98
|
+
* keyId?, releaseId?, bundleId?, releaseSequence?, artifactSha256?,
|
|
99
|
+
* artifactSize?, resultArtifactSha256?, fingerprint? }`. A failed check is
|
|
100
|
+
* `ok:false` with a stable `code`; a rejected promise is machinery failure and
|
|
101
|
+
* the caller must treat it as a failure, never as valid.
|
|
102
|
+
*/
|
|
103
|
+
verifyRelease(envelopeJson: string): Promise<string>;
|
|
104
|
+
/**
|
|
105
|
+
* Native-ENFORCED install: verify the RSA signature (as `verifyRelease`), then
|
|
106
|
+
* download the artifact, enforce the SIGNED size + SHA-256, and stage it into
|
|
107
|
+
* the `next` slot — all natively. JavaScript cannot stage bytes that were not
|
|
108
|
+
* proven authentic (once trusted keys are embedded, the legacy `stageBundle`
|
|
109
|
+
* path refuses). Does not activate; call `applyUpdate` to promote per the
|
|
110
|
+
* install mode. Resolves the same verdict JSON as `verifyRelease` (`ok:true`
|
|
111
|
+
* once staged).
|
|
100
112
|
*/
|
|
101
|
-
|
|
113
|
+
verifyAndStageRelease(envelopeJson: string): Promise<string>;
|
|
102
114
|
}
|
|
103
115
|
/**
|
|
104
116
|
* Resolved via `get` (not `getEnforcing`) so the SDK degrades gracefully when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalebun/react-native",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "React Native SDK for ScaleBun",
|
|
5
5
|
"main": "lib/commonjs/index",
|
|
6
6
|
"module": "lib/module/index",
|
|
@@ -70,8 +70,6 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@babel/preset-env": "^8.0.2",
|
|
73
|
-
"@noble/ed25519": "^3.1.0",
|
|
74
|
-
"@noble/hashes": "^2.3.0",
|
|
75
73
|
"@react-native-community/eslint-config": "^3.2.0",
|
|
76
74
|
"@types/jest": "^29.5.12",
|
|
77
75
|
"@types/node": "^25.3.3",
|
|
@@ -96,9 +94,7 @@
|
|
|
96
94
|
"expo-router": ">=1.0.0",
|
|
97
95
|
"react": ">=18.0.0",
|
|
98
96
|
"react-native": ">=0.74.0",
|
|
99
|
-
"react-native-view-shot": ">=3.0.0"
|
|
100
|
-
"@noble/ed25519": ">=2.0.0",
|
|
101
|
-
"@noble/hashes": ">=2.0.0"
|
|
97
|
+
"react-native-view-shot": ">=3.0.0"
|
|
102
98
|
},
|
|
103
99
|
"peerDependenciesMeta": {
|
|
104
100
|
"react-native-view-shot": {
|
|
@@ -109,19 +105,13 @@
|
|
|
109
105
|
},
|
|
110
106
|
"expo-router": {
|
|
111
107
|
"optional": true
|
|
112
|
-
},
|
|
113
|
-
"@noble/ed25519": {
|
|
114
|
-
"optional": true
|
|
115
|
-
},
|
|
116
|
-
"@noble/hashes": {
|
|
117
|
-
"optional": true
|
|
118
108
|
}
|
|
119
109
|
},
|
|
120
110
|
"dependencies": {
|
|
121
111
|
"@babel/runtime": "^7.25.0",
|
|
122
112
|
"@jridgewell/sourcemap-codec": "1.5.5",
|
|
123
113
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
124
|
-
"@scalebun/cli": "^
|
|
114
|
+
"@scalebun/cli": "^2.0.1"
|
|
125
115
|
},
|
|
126
116
|
"codegenConfig": {
|
|
127
117
|
"name": "ScaleBunSpec",
|
|
@@ -13,6 +13,10 @@ Pod::Spec.new do |s|
|
|
|
13
13
|
# Include .mm (ObjC++) for the New Architecture TurboModule providers added in
|
|
14
14
|
# Phase 2, alongside the existing .h/.m/.swift sources.
|
|
15
15
|
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
16
|
+
# XCTest files ship with the SDK source but must NOT compile into the pod (they
|
|
17
|
+
# import XCTest and belong in a host app's test target). See the run
|
|
18
|
+
# instructions in ScaleBunOtaVerifierTests.swift (PROMPT §23).
|
|
19
|
+
s.exclude_files = "ios/**/*Tests.swift"
|
|
16
20
|
|
|
17
21
|
# Define a clang module so the generated Swift interop header
|
|
18
22
|
# (`scalebun_react_native-Swift.h`) is emitted and importable from a host
|
|
@@ -91,6 +91,24 @@ export class SDKBootstrapper {
|
|
|
91
91
|
// apiBaseUrl is LOCKED: whatever an integrator passed (or the schema default) is
|
|
92
92
|
// discarded here so the SDK can never be repointed away from the ScaleBun cloud.
|
|
93
93
|
// Change DEFAULT_API_BASE_URL at build time to target a different backend.
|
|
94
|
+
// Say so when we discard it. The value was accepted by the schema, silently
|
|
95
|
+
// overwritten here, and the SDK then talked to a different server than the
|
|
96
|
+
// integrator configured — with no log, no warning, and no error. Someone
|
|
97
|
+
// pointing the SDK at a staging or local backend would watch requests arrive
|
|
98
|
+
// at production and have nothing to go on.
|
|
99
|
+
const requestedApiBaseUrl = (rawConfig as any)?.apiBaseUrl;
|
|
100
|
+
if (
|
|
101
|
+
typeof requestedApiBaseUrl === 'string' &&
|
|
102
|
+
requestedApiBaseUrl &&
|
|
103
|
+
requestedApiBaseUrl !== DEFAULT_API_BASE_URL
|
|
104
|
+
) {
|
|
105
|
+
logger.warn(
|
|
106
|
+
`[ScaleBun] Ignoring apiBaseUrl '${requestedApiBaseUrl}' — the endpoint is ` +
|
|
107
|
+
`locked at build time and this build targets ${DEFAULT_API_BASE_URL}. To ` +
|
|
108
|
+
`target another backend, change DEFAULT_API_BASE_URL in the SDK source and ` +
|
|
109
|
+
`rebuild.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
94
112
|
this.config.apiBaseUrl = DEFAULT_API_BASE_URL;
|
|
95
113
|
// Explicit logLevel wins; otherwise debug:true implies verbose 'debug'.
|
|
96
114
|
if (this.config.logLevel) {
|
|
@@ -340,6 +358,28 @@ export class SDKBootstrapper {
|
|
|
340
358
|
await this.featureRegistry.initializeAll(featureContext);
|
|
341
359
|
|
|
342
360
|
// ── Session Replay Initialization (Core) ───────────────
|
|
361
|
+
/**
|
|
362
|
+
* RN-3 zero-config scroll instrumentation, opt-in.
|
|
363
|
+
*
|
|
364
|
+
* Placed before replay init because it patches a module export and must be in place before the
|
|
365
|
+
* host renders its first scroll view. Reports rather than assumes: the equivalent React
|
|
366
|
+
* Navigation hook once claimed success it had not verified and left a whole pipeline silently
|
|
367
|
+
* empty, so a failure here is said out loud with a reason an integrator can act on.
|
|
368
|
+
*/
|
|
369
|
+
if (this.config.autoInstrumentScrollViews === true) {
|
|
370
|
+
try {
|
|
371
|
+
const { autoInstrumentScrollViews } = require('../features/journey/autoInstrumentScroll');
|
|
372
|
+
const outcome = autoInstrumentScrollViews();
|
|
373
|
+
if (!outcome.installed) {
|
|
374
|
+
logger.warn(
|
|
375
|
+
'[Bootstrap] autoInstrumentScrollViews was requested but could not be ' +
|
|
376
|
+
'installed: ' + outcome.reason + ' — taps will carry no content depth.',
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
__DEV__ && logger.debug('[Bootstrap] ScrollView auto-instrumentation installed');
|
|
380
|
+
}
|
|
381
|
+
} catch { /* no-throw */ }
|
|
382
|
+
}
|
|
343
383
|
if (features.replay !== false) {
|
|
344
384
|
try {
|
|
345
385
|
__DEV__ && logger.debug('[Bootstrap] Initializing unified SessionManager');
|
|
@@ -188,6 +188,22 @@ const SHAPE: Record<string, Field> = {
|
|
|
188
188
|
},
|
|
189
189
|
},
|
|
190
190
|
captureInteractionHeatmap: bool(true),
|
|
191
|
+
/**
|
|
192
|
+
* RN-3 zero-config: patch React Native's ScrollView export so EVERY scroll view reports its
|
|
193
|
+
* offset, not just ones wrapped in ScaleBunScrollView.
|
|
194
|
+
*
|
|
195
|
+
* DEFAULT FALSE, unlike captureInteractionHeatmap above, and the difference is deliberate.
|
|
196
|
+
* That flag changes what the SDK records about the host; this one changes what the HOST
|
|
197
|
+
* RENDERS — every scroll view in the app, including ones inside third-party libraries the app
|
|
198
|
+
* did not write, starts routing through an SDK component. The failure mode is not a wrong
|
|
199
|
+
* number in a dashboard, it is the host's UI behaving differently because an analytics SDK
|
|
200
|
+
* replaced a primitive. That is a decision an integrator makes deliberately, not one they
|
|
201
|
+
* discover afterwards.
|
|
202
|
+
*
|
|
203
|
+
* With it off, a plain <ScrollView> yields no content depth and the tap says so
|
|
204
|
+
* (scroll_source: 'none') rather than defaulting its offset to 0.
|
|
205
|
+
*/
|
|
206
|
+
autoInstrumentScrollViews: bool(false),
|
|
191
207
|
// Opt-in: intercept console.* and ship the lines to the Diagnose → Logs lane in SaaS
|
|
192
208
|
// mode. OFF by default — console output can carry PII/secrets and adds ingest volume,
|
|
193
209
|
// so capturing it is the host's explicit choice, not a silent default. When true and a
|
|
@@ -201,12 +217,9 @@ const SHAPE: Record<string, Field> = {
|
|
|
201
217
|
enabled: bool(false),
|
|
202
218
|
checkOnForeground: bool(true),
|
|
203
219
|
channelOverride: { kind: 'str', opt: true },
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// new, drop old next release — no emergency store submission when a
|
|
208
|
-
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
209
|
-
publicSigningKeys: { kind: 'strArr', opt: true },
|
|
220
|
+
// No signing-key fields: signed releases are verified natively against
|
|
221
|
+
// RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
|
|
222
|
+
// not supply a trusted key.
|
|
210
223
|
mandatoryBlocksUi: bool(false),
|
|
211
224
|
},
|
|
212
225
|
},
|
|
@@ -399,15 +412,14 @@ export interface ScaleBunConfig {
|
|
|
399
412
|
redactBodies?: boolean;
|
|
400
413
|
};
|
|
401
414
|
captureInteractionHeatmap: boolean;
|
|
415
|
+
/** RN-3 zero-config scroll instrumentation. OFF by default — it changes what the HOST renders. */
|
|
416
|
+
autoInstrumentScrollViews: boolean;
|
|
402
417
|
/** Opt-in: capture console.* into the Logs lane in SaaS mode. Default false. */
|
|
403
418
|
captureConsoleLogs: boolean;
|
|
404
419
|
ota?: {
|
|
405
420
|
enabled: boolean;
|
|
406
421
|
checkOnForeground: boolean;
|
|
407
422
|
channelOverride?: string;
|
|
408
|
-
publicSigningKey?: string;
|
|
409
|
-
/** Additional pinned keys for rotation; unioned with publicSigningKey. */
|
|
410
|
-
publicSigningKeys?: string[];
|
|
411
423
|
mandatoryBlocksUi: boolean;
|
|
412
424
|
};
|
|
413
425
|
}
|
|
@@ -28,12 +28,15 @@ import React, {
|
|
|
28
28
|
type ReactNode,
|
|
29
29
|
} from 'react';
|
|
30
30
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
31
|
-
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
31
|
+
import { resolveTouchTarget, describeTouchTarget, targetIdentity } from './touchTarget';
|
|
32
|
+
import { localPoint, normalizeRect, readRectSync, type NormalizedRect } from './targetGeometry';
|
|
33
|
+
import { nativeTagOf, nearestRecordedOffset } from './scrollContext';
|
|
32
34
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
33
35
|
import {
|
|
34
36
|
INTERACTION_PROTOCOL_VERSION,
|
|
35
37
|
generateInteractionId,
|
|
36
|
-
|
|
38
|
+
correlateInteraction,
|
|
39
|
+
pruneInteractionStarts,
|
|
37
40
|
type InteractionStartContext,
|
|
38
41
|
} from './interactionProtocol';
|
|
39
42
|
|
|
@@ -53,6 +56,15 @@ function getBootstrap() {
|
|
|
53
56
|
|
|
54
57
|
const JourneyContext = createContext<any>(null);
|
|
55
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Floor on how often a UI-state change may request a screenshot.
|
|
61
|
+
*
|
|
62
|
+
* One frame per second is far finer than any state a person perceives changing, and far coarser
|
|
63
|
+
* than a render loop — which is the case that matters, because a host may legitimately declare
|
|
64
|
+
* state from inside render and an oscillating dimension would otherwise capture continuously.
|
|
65
|
+
*/
|
|
66
|
+
const UI_STATE_CAPTURE_MIN_INTERVAL_MS = 1000;
|
|
67
|
+
|
|
56
68
|
// ─── ScaleBunDebugRoot ───────────────────────────────────────────────────────────
|
|
57
69
|
|
|
58
70
|
interface ScaleBunDebugRootProps {
|
|
@@ -275,6 +287,50 @@ export function ScaleBunDebugRoot({
|
|
|
275
287
|
};
|
|
276
288
|
}, [navigationRef]);
|
|
277
289
|
|
|
290
|
+
/**
|
|
291
|
+
* RN-11 — CAPTURE A FRAME WHEN THE UI CHANGES SHAPE.
|
|
292
|
+
*
|
|
293
|
+
* A declared state used to have no picture of its own. Nothing captured on `setUiState`, so a
|
|
294
|
+
* state's visual existed only if a screenshot happened to be taken for another reason — a
|
|
295
|
+
* navigation, a timer, or the interaction capture that fires AFTER the touch and therefore
|
|
296
|
+
* photographs the outcome. A drawer could be recorded as open with no frame showing it open,
|
|
297
|
+
* or with the frame of the screen that replaced it.
|
|
298
|
+
*
|
|
299
|
+
* The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER; none of them means
|
|
300
|
+
* "the UI changed shape". This routes through the MANUAL path, which is the only one reachable
|
|
301
|
+
* from JS. A dedicated native reason would let the backend tell a state capture from a
|
|
302
|
+
* developer-requested one, and that is a native change on both platforms — worth doing, not
|
|
303
|
+
* doable from here.
|
|
304
|
+
*
|
|
305
|
+
* RATE LIMITED, and the limit is not politeness. A screenshot is expensive, and while
|
|
306
|
+
* `uiState` already collapses redundant declarations, a genuinely oscillating dimension (a
|
|
307
|
+
* spinner toggling, a live-updating badge) would otherwise capture continuously. One frame per
|
|
308
|
+
* second is far finer than any state a person can perceive changing and far coarser than a
|
|
309
|
+
* render loop.
|
|
310
|
+
*
|
|
311
|
+
* Requests only; never awaits and never throws. If the session is not recording,
|
|
312
|
+
* `captureManualFrame` declines and the state simply has no frame — which is the honest
|
|
313
|
+
* outcome and is exactly what the analytics-only lane already reports elsewhere.
|
|
314
|
+
*/
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
let lastCaptureTs = 0;
|
|
317
|
+
const { onUiStateChange } = require('./uiState');
|
|
318
|
+
const unsubscribe = onUiStateChange(() => {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (now - lastCaptureTs < UI_STATE_CAPTURE_MIN_INTERVAL_MS) return;
|
|
321
|
+
lastCaptureTs = now;
|
|
322
|
+
try {
|
|
323
|
+
const { SessionManager } = require('../session/SessionManager');
|
|
324
|
+
const sm = SessionManager.getExistingInstance();
|
|
325
|
+
if (sm && typeof (sm as any).captureManualFrame === 'function') {
|
|
326
|
+
/* Fire and forget: the tap that follows must not wait on a screenshot. */
|
|
327
|
+
void (sm as any).captureManualFrame();
|
|
328
|
+
}
|
|
329
|
+
} catch { /* no-throw */ }
|
|
330
|
+
});
|
|
331
|
+
return unsubscribe;
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
278
334
|
// ─── Native Interaction Listener ────────────────────────────────────
|
|
279
335
|
// Receives high-confidence ScaleBunNativeInteraction events from the native tracker
|
|
280
336
|
// and forwards them to SessionManager as USER_ACTION events.
|
|
@@ -301,7 +357,30 @@ export function ScaleBunDebugRoot({
|
|
|
301
357
|
? event.occurredAt
|
|
302
358
|
: (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) -
|
|
303
359
|
(typeof event.durationMs === 'number' ? event.durationMs : 0);
|
|
304
|
-
|
|
360
|
+
/**
|
|
361
|
+
* RN-6 — correlate, then CONSUME. See interactionProtocol.ts for why
|
|
362
|
+
* the join is on time at all, and why a refusal is the important part.
|
|
363
|
+
*
|
|
364
|
+
* Pruning first is part of the correctness, not tidying: a start older
|
|
365
|
+
* than the tolerance can never be paired, but while it sits in the list
|
|
366
|
+
* it is still counted by the ambiguity test, so a stale entry can turn a
|
|
367
|
+
* clear pairing into a refusal — and the list only grows.
|
|
368
|
+
*/
|
|
369
|
+
pruneInteractionStarts(interactionStartsRef.current, nativeOccurredAt);
|
|
370
|
+
const correlation = correlateInteraction(
|
|
371
|
+
interactionStartsRef.current,
|
|
372
|
+
nativeOccurredAt,
|
|
373
|
+
);
|
|
374
|
+
const start = correlation.start;
|
|
375
|
+
/**
|
|
376
|
+
* ONE-TO-ONE. Without removing the matched start, a second native event
|
|
377
|
+
* arriving within the window claims the SAME js evidence, so one tap's
|
|
378
|
+
* target is stamped onto another tap that never had one — a duplicate
|
|
379
|
+
* identity that reads as two genuine interactions on the same control.
|
|
380
|
+
*/
|
|
381
|
+
if (correlation.index >= 0) {
|
|
382
|
+
interactionStartsRef.current.splice(correlation.index, 1);
|
|
383
|
+
}
|
|
305
384
|
|
|
306
385
|
// Cancel only this physical touch's fallback. A single timer used to cancel a
|
|
307
386
|
// previous rapid tap and silently lose it on devices without native tracking.
|
|
@@ -330,11 +409,63 @@ export function ScaleBunDebugRoot({
|
|
|
330
409
|
interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
|
|
331
410
|
interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
|
|
332
411
|
occurredAt: nativeOccurredAt,
|
|
412
|
+
/**
|
|
413
|
+
* RN-6 — THESE FIVE FIELDS ARE THE ONLY ONES THE JOIN CARRIES, AND
|
|
414
|
+
* THEREFORE THE ONLY ONES A BAD JOIN CAN FALSIFY.
|
|
415
|
+
*
|
|
416
|
+
* Coordinates below come from native and are right regardless. So a
|
|
417
|
+
* mispairing does not produce visibly broken data: it produces heat
|
|
418
|
+
* in the correct position attributed to the wrong control, in the
|
|
419
|
+
* wrong UI state, on the wrong screen. Every field is individually
|
|
420
|
+
* well-formed and nothing downstream can tell.
|
|
421
|
+
*
|
|
422
|
+
* When the correlation is not 'exact' they are left UNDEFINED rather
|
|
423
|
+
* than filled from a best guess. An interaction with no target is
|
|
424
|
+
* honest and visibly incomplete — the accounting lane can count it as
|
|
425
|
+
* unplaceable and say why. An interaction with the WRONG target is
|
|
426
|
+
* indistinguishable from a correct one and silently corrupts every
|
|
427
|
+
* aggregate it enters. `null` is not `0`, and a guess is not a
|
|
428
|
+
* measurement.
|
|
429
|
+
*/
|
|
333
430
|
ui: start?.ui,
|
|
334
431
|
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
335
432
|
target: start?.target,
|
|
336
433
|
targetId: start?.targetId,
|
|
434
|
+
targetSource: start?.targetSource,
|
|
337
435
|
screenName: start?.screenName,
|
|
436
|
+
screenSource: start?.screenSource ?? 'none',
|
|
437
|
+
targetRect: start?.targetRect,
|
|
438
|
+
/**
|
|
439
|
+
* RN-15 — WHERE IN THE CONTROL, computed where both halves exist: the
|
|
440
|
+
* point comes from the native event and the rect was measured at
|
|
441
|
+
* finger-down, in the same capture base. Pairing them here rather than
|
|
442
|
+
* downstream stops anything later reaching for a different rect.
|
|
443
|
+
*/
|
|
444
|
+
...(() => {
|
|
445
|
+
const lp = localPoint(
|
|
446
|
+
start?.targetRect ?? null,
|
|
447
|
+
event.normalizedX,
|
|
448
|
+
event.normalizedY,
|
|
449
|
+
);
|
|
450
|
+
return lp ? { localU: lp.u, localV: lp.v } : {};
|
|
451
|
+
})(),
|
|
452
|
+
scrollX: start?.scrollX,
|
|
453
|
+
scrollY: start?.scrollY,
|
|
454
|
+
scrollSource: start?.scrollSource ?? 'none',
|
|
455
|
+
itemKey: start?.itemKey,
|
|
456
|
+
itemIndex: start?.itemIndex,
|
|
457
|
+
/**
|
|
458
|
+
* The join's own verdict, shipped with the row so it is auditable
|
|
459
|
+
* rather than a claim made in a comment. 'ambiguous' and 'none' are
|
|
460
|
+
* deliberately distinguished: 'none' means the JS handler never fired
|
|
461
|
+
* (a real capture gap on this platform), 'ambiguous' means it fired
|
|
462
|
+
* more than once too close together to attribute. Those have
|
|
463
|
+
* different fixes, and collapsing them would hide which one is
|
|
464
|
+
* actually happening in production.
|
|
465
|
+
*/
|
|
466
|
+
correlation: correlation.outcome,
|
|
467
|
+
correlationDeltaMs: correlation.deltaMs,
|
|
468
|
+
correlationRunnerUpDeltaMs: correlation.runnerUpDeltaMs,
|
|
338
469
|
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
339
470
|
x: event.rawX,
|
|
340
471
|
y: event.rawY,
|
|
@@ -411,13 +542,28 @@ export function ScaleBunDebugRoot({
|
|
|
411
542
|
const target = resolveTouchTarget(e);
|
|
412
543
|
let ui: string | undefined;
|
|
413
544
|
let screenName: string | undefined;
|
|
545
|
+
/**
|
|
546
|
+
* RN-8 — WHERE the screen name came from, carried with the interaction.
|
|
547
|
+
*
|
|
548
|
+
* Production shows 13.8% of Android interactions with a screen and 0.5% on iOS, and
|
|
549
|
+
* those figures cannot distinguish three unrelated causes: nothing was instrumented,
|
|
550
|
+
* a manual beacon was instrumented but never reached interactions, or auto-detection
|
|
551
|
+
* ran and failed. Each has a different owner. Recording the source turns one
|
|
552
|
+
* unactionable percentage into three answerable ones.
|
|
553
|
+
*/
|
|
554
|
+
let screenSource: 'manual' | 'navigation' | 'none' = 'none';
|
|
414
555
|
try {
|
|
415
556
|
const { uiStateSignature } = require('./uiState');
|
|
416
557
|
ui = uiStateSignature();
|
|
417
558
|
} catch { /* no-throw */ }
|
|
418
559
|
try {
|
|
419
560
|
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
420
|
-
|
|
561
|
+
const detector = AutoScreenDetector.getInstance();
|
|
562
|
+
screenName = detector.getCurrentScreen() || undefined;
|
|
563
|
+
/* Read from the SAME call site as the name, so the two can never describe
|
|
564
|
+
different moments — the detector is polled and its answer can change between
|
|
565
|
+
two reads a few milliseconds apart. */
|
|
566
|
+
screenSource = screenName ? detector.getCurrentScreenSource() : 'none';
|
|
421
567
|
} catch { /* no-throw */ }
|
|
422
568
|
const start: TouchStartContext = {
|
|
423
569
|
interactionId: generateInteractionId(),
|
|
@@ -426,9 +572,88 @@ export function ScaleBunDebugRoot({
|
|
|
426
572
|
y: touch.pageY,
|
|
427
573
|
locationX: touch.locationX,
|
|
428
574
|
locationY: touch.locationY,
|
|
575
|
+
/**
|
|
576
|
+
* RN-1 + RN-2 — the identity that is persisted, and it is now code-derived.
|
|
577
|
+
*
|
|
578
|
+
* `describeTouchTarget` returns `testID` -> structural path -> component name,
|
|
579
|
+
* and never a caption or an accessibilityLabel. Before RN-2 it preferred free
|
|
580
|
+
* text, so this line was writing user data ("Pay EGP 1,442", an order row
|
|
581
|
+
* labelled with an email address, a contact's name) into the analytics store —
|
|
582
|
+
* and, because `target` is an aggregation key, splitting one button into one
|
|
583
|
+
* bucket per person at the same time.
|
|
584
|
+
*
|
|
585
|
+
* `targetId` used to be `target?.testID` alone, which is `undefined` in the
|
|
586
|
+
* overwhelming majority of apps — no testIDs authored — so the field the
|
|
587
|
+
* backend keys on was empty almost always. It now falls back to the structural
|
|
588
|
+
* path, with `targetSource` recording WHICH it got.
|
|
589
|
+
*/
|
|
429
590
|
target: describeTouchTarget(target),
|
|
430
|
-
targetId: target
|
|
591
|
+
targetId: targetIdentity(target).id,
|
|
592
|
+
/**
|
|
593
|
+
* WHICH KIND of identity this is, because they are not equally trustworthy: a
|
|
594
|
+
* `testID` is a promise from the app's authors that this control is this
|
|
595
|
+
* control; a `path` is an inference that breaks when the tree is refactored.
|
|
596
|
+
* Without this the accounting lane cannot distinguish "the control had no
|
|
597
|
+
* identity" from "the identity is a structural guess", and both would render
|
|
598
|
+
* as an equally confident target.
|
|
599
|
+
*/
|
|
600
|
+
targetSource: targetIdentity(target).source,
|
|
601
|
+
/* RN-4 — row identity from the app's own keyExtractor; see touchTarget.ts. */
|
|
602
|
+
itemKey: target?.itemKey,
|
|
603
|
+
itemIndex: target?.itemIndex,
|
|
604
|
+
/**
|
|
605
|
+
* RN-13 — the control's rectangle, so `heat ∈ target` is decidable.
|
|
606
|
+
*
|
|
607
|
+
* Read SYNCHRONOUSLY here, in the same tick as the touch, and normalized against
|
|
608
|
+
* the SAME measured root the tap's own coordinates use. Both halves matter:
|
|
609
|
+
*
|
|
610
|
+
* - A callback measurement (`measureInWindow`) would resolve after this handler
|
|
611
|
+
* returns, so the rect attached to a tap could be the previous tap's, with
|
|
612
|
+
* nothing afterwards able to tell. `targetGeometry` refuses to guess: it reads
|
|
613
|
+
* now or reports null.
|
|
614
|
+
* - Normalizing against a window size instead of the root view is off by the
|
|
615
|
+
* status bar on most devices, which would read as "the heat is just outside
|
|
616
|
+
* the button" everywhere rather than as a measurement bug.
|
|
617
|
+
*
|
|
618
|
+
* `measureRoot()` is called first because the cached root rect is populated
|
|
619
|
+
* asynchronously at mount; without it the first taps of a session normalize
|
|
620
|
+
* against nothing and get a null rect for a reason that has nothing to do with
|
|
621
|
+
* the control.
|
|
622
|
+
*/
|
|
623
|
+
/**
|
|
624
|
+
* RN-3 — WHERE IN THE CONTENT, not just where on the glass.
|
|
625
|
+
*
|
|
626
|
+
* Two taps at the same screen coordinate one viewport apart are two different
|
|
627
|
+
* controls. Without an offset they merge into one hotspot, and the heatmap shows
|
|
628
|
+
* heavy engagement at a position where, in content terms, nothing in particular
|
|
629
|
+
* is. The offset is also a CACHE dimension — two scroll states of one route must
|
|
630
|
+
* not share a backdrop — so its absence corrupts the picture as well as the count.
|
|
631
|
+
*
|
|
632
|
+
* `ancestors` is innermost-first, so this takes the NEAREST scroller that has a
|
|
633
|
+
* recorded position: a tap inside a horizontal carousel belongs to the carousel's
|
|
634
|
+
* content, not to how far the page behind it had scrolled.
|
|
635
|
+
*
|
|
636
|
+
* Null when no ancestor has one — never zero. Zero is a real scroll position (the
|
|
637
|
+
* top of a list), and reporting it for "unknown" would place every unmeasured tap
|
|
638
|
+
* at the top of its content, indistinguishable from a genuine tap there.
|
|
639
|
+
*/
|
|
640
|
+
...(() => {
|
|
641
|
+
const hit = nearestRecordedOffset(
|
|
642
|
+
(target?.ancestors ?? []).map((n) => nativeTagOf(n)),
|
|
643
|
+
);
|
|
644
|
+
return hit
|
|
645
|
+
? { scrollX: hit.x, scrollY: hit.y, scrollSource: 'wrapper' as const }
|
|
646
|
+
: { scrollSource: 'none' as const };
|
|
647
|
+
})(),
|
|
648
|
+
targetRect: (() => {
|
|
649
|
+
measureRoot();
|
|
650
|
+
const root = rootRectRef.current;
|
|
651
|
+
/* null -> undefined: the context field is optional, and an explicit null would
|
|
652
|
+
serialise as a JSON null claiming "measured, and it was nothing". */
|
|
653
|
+
return normalizeRect(readRectSync(target?.node), root ? { w: root.w, h: root.h } : null) ?? undefined;
|
|
654
|
+
})(),
|
|
431
655
|
screenName,
|
|
656
|
+
screenSource,
|
|
432
657
|
ui,
|
|
433
658
|
stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
|
|
434
659
|
emitAutomaticAnalytics: captureAutomaticInteractions,
|
|
@@ -473,7 +698,20 @@ export function ScaleBunDebugRoot({
|
|
|
473
698
|
// WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
|
|
474
699
|
// carries only coordinates, so every tap in the app groups into one row per gesture type.
|
|
475
700
|
// Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
|
|
476
|
-
|
|
701
|
+
/**
|
|
702
|
+
* Resolved ONCE, so `target` and `targetId` cannot disagree about the same tap.
|
|
703
|
+
*
|
|
704
|
+
* The previous shape called `resolveTouchTarget(e)` inline for `target` while
|
|
705
|
+
* `targetId` came only from `start` — so on the fallback path (no JS start, or a
|
|
706
|
+
* refused correlation) the tap carried a description with NO id, and the two fields
|
|
707
|
+
* were computed from different sources. Anything joining on `targetId` therefore
|
|
708
|
+
* saw nothing for a tap that visibly had a target.
|
|
709
|
+
*/
|
|
710
|
+
const freshTarget = start ? null : resolveTouchTarget(e);
|
|
711
|
+
const tapped = start?.target ?? describeTouchTarget(freshTarget);
|
|
712
|
+
const identity = start
|
|
713
|
+
? { id: start.targetId, source: start.targetSource }
|
|
714
|
+
: targetIdentity(freshTarget);
|
|
477
715
|
|
|
478
716
|
const gestureDetails: any = {
|
|
479
717
|
target: tapped,
|
|
@@ -482,8 +720,16 @@ export function ScaleBunDebugRoot({
|
|
|
482
720
|
occurredAt: start?.occurredAt ?? Date.now(),
|
|
483
721
|
ui: start?.ui,
|
|
484
722
|
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
485
|
-
targetId:
|
|
723
|
+
targetId: identity.id,
|
|
724
|
+
targetSource: identity.source,
|
|
486
725
|
screenName: start?.screenName,
|
|
726
|
+
screenSource: start?.screenSource ?? 'none',
|
|
727
|
+
targetRect: start?.targetRect,
|
|
728
|
+
scrollX: start?.scrollX,
|
|
729
|
+
scrollY: start?.scrollY,
|
|
730
|
+
scrollSource: start?.scrollSource ?? 'none',
|
|
731
|
+
itemKey: start?.itemKey,
|
|
732
|
+
itemIndex: start?.itemIndex,
|
|
487
733
|
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
488
734
|
x: start?.x ?? touch.pageX,
|
|
489
735
|
y: start?.y ?? touch.pageY,
|
|
@@ -506,6 +752,19 @@ export function ScaleBunDebugRoot({
|
|
|
506
752
|
const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
|
|
507
753
|
gestureDetails.normalizedX = nx;
|
|
508
754
|
gestureDetails.normalizedY = ny;
|
|
755
|
+
/**
|
|
756
|
+
* RN-15 on the JS-fallback lane.
|
|
757
|
+
*
|
|
758
|
+
* Computed here rather than in the literal above because that is where nx/ny first
|
|
759
|
+
* exist. The rect is the SAME one the native lane uses, so one physical tap yields
|
|
760
|
+
* the same local position whichever lane captured it — two lanes disagreeing about
|
|
761
|
+
* where in a control a tap landed would be worse than neither reporting it.
|
|
762
|
+
*/
|
|
763
|
+
const lp = localPoint(start?.targetRect ?? null, nx, ny);
|
|
764
|
+
if (lp) {
|
|
765
|
+
gestureDetails.localU = lp.u;
|
|
766
|
+
gestureDetails.localV = lp.v;
|
|
767
|
+
}
|
|
509
768
|
gestureDetails.normalizedPrecomputed = true;
|
|
510
769
|
gestureDetails.viewportWidth = rootRect.w;
|
|
511
770
|
gestureDetails.viewportHeight = rootRect.h;
|
|
@@ -695,6 +954,28 @@ export function useScaleBunScreen(name: string): void {
|
|
|
695
954
|
}
|
|
696
955
|
} catch { /* no-throw */ }
|
|
697
956
|
|
|
957
|
+
/**
|
|
958
|
+
* RN-8 — TELL THE DETECTOR TOO, because interactions read their screen from IT.
|
|
959
|
+
*
|
|
960
|
+
* This hook fed SessionManager, PerformanceFeature, ProfilerFeature and JourneyManager
|
|
961
|
+
* — four consumers — and never `AutoScreenDetector`. But `handleTouchStart` resolves an
|
|
962
|
+
* interaction's `screenName` from `AutoScreenDetector.getCurrentScreen()`, so a
|
|
963
|
+
* manually-instrumented app produced perfectly-named SCREEN_VIEW events while every TAP
|
|
964
|
+
* carried no screen at all. `setManualScreen` existed and was called only by tests.
|
|
965
|
+
*
|
|
966
|
+
* That is the whole story for apps the auto-hook cannot help: expo-router, a custom
|
|
967
|
+
* navigator, anything not `@react-navigation/native`. The documented manual escape hatch
|
|
968
|
+
* did not reach the one field that partitions every heatmap.
|
|
969
|
+
*
|
|
970
|
+
* Requires the last-writer-wins fix in `_currentScreen()` to be useful: under the
|
|
971
|
+
* previous rule a manual screen was readable for only 2000 ms, so wiring this alone
|
|
972
|
+
* would have handed interactions a name that expired before most taps happened.
|
|
973
|
+
*/
|
|
974
|
+
try {
|
|
975
|
+
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
976
|
+
AutoScreenDetector.getInstance().setManualScreen(name);
|
|
977
|
+
} catch { /* no-throw */ }
|
|
978
|
+
|
|
698
979
|
// Feed PerformanceFeature (screen context for perf metrics)
|
|
699
980
|
// NOTE: Only sets screen identity. Does NOT call markScreenLoadStart/End.
|
|
700
981
|
// AutoScreenLoadCollector handles timing independently via rAF + InteractionManager.
|