sitepong 0.2.8 → 0.2.10

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.
@@ -3315,1319 +3315,1511 @@ function SensitiveView(props) {
3315
3315
  );
3316
3316
  }
3317
3317
 
3318
- // src/react-native/error-handler.ts
3319
- function setupRNErrorHandler(options = {}) {
3320
- const {
3321
- captureFatal = true,
3322
- captureNonFatal = true,
3323
- capturePromiseRejections = true
3324
- } = options;
3325
- const teardowns = [];
3326
- if (typeof ErrorUtils !== "undefined") {
3327
- const previousHandler = ErrorUtils.getGlobalHandler();
3328
- ErrorUtils.setGlobalHandler((error, isFatal) => {
3329
- if (isFatal && captureFatal || !isFatal && captureNonFatal) {
3330
- captureError(error, {
3331
- tags: {
3332
- fatal: String(isFatal),
3333
- handler: "react-native-global"
3334
- }
3335
- });
3336
- flushScreenRecording().catch(() => {
3337
- });
3338
- }
3339
- if (previousHandler) {
3340
- previousHandler(error, isFatal);
3341
- }
3342
- });
3343
- teardowns.push(() => {
3344
- if (previousHandler) {
3345
- ErrorUtils.setGlobalHandler(previousHandler);
3346
- }
3347
- });
3348
- }
3349
- if (capturePromiseRejections) {
3350
- if (typeof global !== "undefined") {
3351
- const rejectionHandler = (event) => {
3352
- const err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
3353
- captureError(err, {
3354
- tags: { handler: "unhandled-promise-rejection" }
3355
- });
3356
- flushScreenRecording().catch(() => {
3357
- });
3358
- };
3359
- if (typeof globalThis.addEventListener === "function") {
3360
- globalThis.addEventListener("unhandledrejection", rejectionHandler);
3361
- teardowns.push(() => {
3362
- globalThis.removeEventListener("unhandledrejection", rejectionHandler);
3363
- });
3364
- }
3365
- }
3366
- }
3367
- return () => {
3368
- for (const teardown of teardowns) {
3369
- teardown();
3370
- }
3371
- };
3372
- }
3373
-
3374
- // src/react-native/navigation.ts
3375
- function createNavigationTracker(navigationRef, options = {}) {
3376
- const {
3377
- trackScreenViews = true,
3378
- addBreadcrumbs = true,
3379
- screenNameFilter
3380
- } = options;
3381
- let currentRouteName;
3382
- const unsubscribe = navigationRef.addListener("state", () => {
3383
- const route = navigationRef.getCurrentRoute();
3384
- if (!route) return;
3385
- const previousRouteName = currentRouteName;
3386
- currentRouteName = route.name;
3387
- if (previousRouteName === currentRouteName) return;
3388
- const screenName = screenNameFilter ? screenNameFilter(currentRouteName) : currentRouteName;
3389
- if (!screenName) return;
3390
- setCurrentScreen(screenName);
3391
- if (trackScreenViews) {
3392
- track("$screen_view", {
3393
- screen_name: screenName,
3394
- previous_screen: previousRouteName,
3395
- params: route.params
3396
- });
3397
- }
3398
- if (addBreadcrumbs) {
3399
- addBreadcrumb({
3400
- type: "navigation",
3401
- category: "screen",
3402
- message: `Navigated to ${screenName}`,
3403
- data: {
3404
- from: previousRouteName,
3405
- to: screenName
3406
- }
3407
- });
3408
- }
3409
- });
3410
- return () => {
3411
- unsubscribe();
3412
- setCurrentScreen(void 0);
3413
- };
3414
- }
3415
-
3416
- // src/react-native/network.ts
3417
- function setupNetworkInterception(options = {}) {
3418
- const {
3419
- addBreadcrumbs = true,
3420
- ignoreUrls = [/sitepong\.com/],
3421
- maxDataSize = 1024
3422
- } = options;
3423
- if (typeof XMLHttpRequest === "undefined") {
3424
- return () => {
3425
- };
3426
- }
3427
- const originalOpen = XMLHttpRequest.prototype.open;
3428
- const originalSend = XMLHttpRequest.prototype.send;
3429
- XMLHttpRequest.prototype.open = function(method, url, ...rest) {
3430
- const xhr = this;
3431
- xhr._sitepong_method = method;
3432
- xhr._sitepong_url = String(url);
3433
- return originalOpen.apply(this, [method, url, ...rest]);
3434
- };
3435
- XMLHttpRequest.prototype.send = function(body) {
3436
- const xhr = this;
3437
- const url = xhr._sitepong_url || "";
3438
- const method = xhr._sitepong_method || "GET";
3439
- const shouldIgnore = ignoreUrls.some((pattern) => {
3440
- if (typeof pattern === "string") return url.includes(pattern);
3441
- return pattern.test(url);
3442
- });
3443
- if (shouldIgnore) {
3444
- return originalSend.call(this, body);
3445
- }
3446
- xhr._sitepong_startTime = Date.now();
3447
- const onLoadEnd = () => {
3448
- const duration = xhr._sitepong_startTime ? Date.now() - xhr._sitepong_startTime : void 0;
3449
- if (addBreadcrumbs) {
3450
- addBreadcrumb({
3451
- type: "http",
3452
- category: "xhr",
3453
- message: `${method} ${url}`,
3454
- level: xhr.status >= 400 ? "error" : "info",
3455
- data: {
3456
- method,
3457
- url: url.length > maxDataSize ? url.substring(0, maxDataSize) : url,
3458
- status_code: xhr.status,
3459
- duration_ms: duration
3460
- }
3461
- });
3462
- }
3463
- };
3464
- this.addEventListener("loadend", onLoadEnd);
3465
- return originalSend.call(this, body);
3466
- };
3467
- return () => {
3468
- XMLHttpRequest.prototype.open = originalOpen;
3469
- XMLHttpRequest.prototype.send = originalSend;
3318
+ // ../watchtower-recorder/dist/index.mjs
3319
+ var CreateElementNode = (id, parentID, index, tag) => [8, id, parentID, index, tag];
3320
+ var CreateTextNode = (id, parentID, index) => [
3321
+ 9,
3322
+ id,
3323
+ parentID,
3324
+ index
3325
+ ];
3326
+ var MoveNode = (id, parentID, index) => [
3327
+ 10,
3328
+ id,
3329
+ parentID,
3330
+ index
3331
+ ];
3332
+ var RemoveNode = (id) => [11, id];
3333
+ var SetNodeAttribute = (id, name, value) => [
3334
+ 12,
3335
+ id,
3336
+ name,
3337
+ value
3338
+ ];
3339
+ var RemoveNodeAttribute = (id, name) => [
3340
+ 13,
3341
+ id,
3342
+ name
3343
+ ];
3344
+ var SetNodeData = (id, data) => [14, id, data];
3345
+ var SetNodeScroll = (id, x, y) => [
3346
+ 16,
3347
+ id,
3348
+ x,
3349
+ y
3350
+ ];
3351
+ var MobileTap = (id, hesitationMs, label, selector, normX, normY) => [68, id, hesitationMs, label, selector, normX, normY];
3352
+ var ScreenView = (name, prevName) => [
3353
+ 200,
3354
+ name,
3355
+ prevName
3356
+ ];
3357
+ var Frustration = (kind, nodeId, selector, count) => [201, kind, nodeId, selector, count];
3358
+ var TapOutcome = (tapNodeId, kind, label, mutationCount) => [202, tapNodeId, kind, label, mutationCount];
3359
+ function walk(root, visit) {
3360
+ const rec = (node, parent, index) => {
3361
+ visit(node, parent, index);
3362
+ node.children.forEach((child, i) => rec(child, node, i));
3470
3363
  };
3364
+ if (root) rec(root, null, 0);
3471
3365
  }
3472
- var RNAutocaptureModule = class {
3473
- constructor(options = {}) {
3474
- this.appStateSubscription = null;
3475
- this.currentAppState = "active";
3476
- this.options = {
3477
- trackAppState: options.trackAppState ?? true
3478
- };
3479
- }
3480
- start() {
3481
- if (this.options.trackAppState) {
3482
- this.setupAppStateTracking();
3483
- }
3484
- }
3485
- stop() {
3486
- if (this.appStateSubscription) {
3487
- this.appStateSubscription.remove();
3488
- this.appStateSubscription = null;
3489
- }
3490
- }
3491
- setupAppStateTracking() {
3492
- try {
3493
- this.currentAppState = reactNative.AppState.currentState;
3494
- this.appStateSubscription = reactNative.AppState.addEventListener("change", (nextAppState) => {
3495
- const previousState = this.currentAppState;
3496
- this.currentAppState = nextAppState;
3497
- if (previousState !== nextAppState) {
3498
- track("$app_state_change", {
3499
- from: previousState,
3500
- to: nextAppState
3501
- });
3502
- if (previousState.match(/inactive|background/) && nextAppState === "active") {
3503
- track("$app_foreground", {
3504
- previous_state: previousState
3505
- });
3506
- } else if (previousState === "active" && nextAppState.match(/inactive|background/)) {
3507
- track("$app_background", {
3508
- next_state: nextAppState
3509
- });
3510
- }
3511
- }
3512
- });
3513
- } catch {
3514
- }
3515
- }
3516
- };
3517
-
3518
- // src/react-native/performance.ts
3519
- var coldStartTimestamp = null;
3520
- var coldStartTracked = false;
3521
- function markColdStart() {
3522
- coldStartTimestamp = Date.now();
3523
- }
3524
- var RNPerformanceManager = class {
3366
+ var Mirror = class {
3525
3367
  constructor() {
3526
- this.screenRenderStarts = /* @__PURE__ */ new Map();
3368
+ __publicField(this, "ids", /* @__PURE__ */ new WeakMap());
3369
+ __publicField(this, "byId", /* @__PURE__ */ new Map());
3370
+ __publicField(this, "next", 1);
3527
3371
  }
3528
- /**
3529
- * Track cold start time from markColdStart() to now.
3530
- * Should be called once the app is interactive (e.g., in the root component's useEffect).
3531
- */
3532
- trackColdStart() {
3533
- if (coldStartTracked || !coldStartTimestamp) return;
3534
- const duration = Date.now() - coldStartTimestamp;
3535
- coldStartTracked = true;
3536
- track("$cold_start", {
3537
- duration_ms: duration
3538
- });
3372
+ // 0 is reserved for "no/root parent"
3373
+ /** Existing id for a node, or undefined if never registered. */
3374
+ peek(key) {
3375
+ return this.ids.get(key);
3539
3376
  }
3540
- /**
3541
- * Start tracking a screen render. Call before rendering starts.
3542
- */
3543
- startScreenRender(screenName) {
3544
- this.screenRenderStarts.set(screenName, Date.now());
3377
+ /** Id for a node, assigning a fresh one on first sight. Second tuple element
3378
+ * is true when the id was just created (→ emit a Create* message). */
3379
+ getId(key) {
3380
+ const existing = this.ids.get(key);
3381
+ if (existing !== void 0) return [existing, false];
3382
+ const id = this.next++;
3383
+ this.ids.set(key, id);
3384
+ this.byId.set(id, key);
3385
+ return [id, true];
3545
3386
  }
3546
- /**
3547
- * End tracking a screen render. Call after rendering completes.
3548
- */
3549
- endScreenRender(screenName) {
3550
- const startTime = this.screenRenderStarts.get(screenName);
3551
- if (startTime === void 0) return null;
3552
- this.screenRenderStarts.delete(screenName);
3553
- const duration = Date.now() - startTime;
3554
- track("$screen_render", {
3555
- screen_name: screenName,
3556
- duration_ms: duration
3557
- });
3558
- return duration;
3387
+ getKey(id) {
3388
+ return this.byId.get(id);
3559
3389
  }
3560
- /**
3561
- * Track a screen render using a useEffect-friendly pattern.
3562
- * Returns a cleanup function.
3563
- */
3564
- trackScreenRender(screenName) {
3565
- this.startScreenRender(screenName);
3566
- return () => {
3567
- this.endScreenRender(screenName);
3568
- };
3390
+ /** Drop a node's id (on unmount). Idempotent. */
3391
+ release(key) {
3392
+ const id = this.ids.get(key);
3393
+ if (id === void 0) return void 0;
3394
+ this.ids.delete(key);
3395
+ this.byId.delete(id);
3396
+ return id;
3397
+ }
3398
+ get size() {
3399
+ return this.byId.size;
3569
3400
  }
3570
3401
  };
3571
-
3572
- // src/react-native/push.ts
3573
- var cachedDeviceContext = null;
3574
- function getEndpoint() {
3575
- const cfg = sitepong.config;
3576
- return cfg?.endpoint || "https://ingest.sitepong.com";
3577
- }
3578
- function getApiKey() {
3579
- const cfg = sitepong.config;
3580
- return cfg?.apiKey || "";
3581
- }
3582
- function getDeviceContext() {
3583
- if (cachedDeviceContext) return cachedDeviceContext;
3584
- try {
3585
- const info = collectDeviceInfo();
3586
- cachedDeviceContext = {
3587
- platform: info.platform,
3588
- appVersion: info.appVersion,
3589
- deviceModel: info.deviceModel,
3590
- osVersion: info.osVersion
3591
- };
3592
- fetchPersistentDeviceId().then((id) => {
3593
- if (id && cachedDeviceContext) cachedDeviceContext.deviceId = id;
3402
+ var emptyIndex = () => /* @__PURE__ */ new Map();
3403
+ function indexTree(tree, mirror, prev) {
3404
+ const next = /* @__PURE__ */ new Map();
3405
+ const order = [];
3406
+ walk(tree, (node, parent, idx) => {
3407
+ const [id] = mirror.getId(node.key);
3408
+ const parentId = parent ? mirror.getId(parent.key)[0] : 0;
3409
+ next.set(id, {
3410
+ id,
3411
+ parentId,
3412
+ index: idx,
3413
+ tag: node.tag,
3414
+ attrs: node.attrs,
3415
+ text: node.text
3594
3416
  });
3595
- } catch {
3596
- cachedDeviceContext = {};
3597
- }
3598
- return cachedDeviceContext;
3417
+ order.push(id);
3418
+ });
3419
+ return { next, order };
3599
3420
  }
3600
- async function postToIngest(path, body) {
3601
- const endpoint = getEndpoint();
3602
- const apiKey = getApiKey();
3603
- if (!apiKey) {
3604
- console.warn("[SitePong] Cannot register push token: SDK not initialized");
3605
- return;
3421
+ function attrDiff(id, prev, next) {
3422
+ const out = [];
3423
+ for (const k of Object.keys(next)) {
3424
+ if (prev[k] !== next[k]) out.push(SetNodeAttribute(id, k, next[k]));
3606
3425
  }
3607
- try {
3608
- const response = await fetch(`${endpoint}${path}`, {
3609
- method: "POST",
3610
- headers: {
3611
- "Content-Type": "application/json",
3612
- "X-API-Key": apiKey
3613
- },
3614
- body: JSON.stringify(body)
3615
- });
3616
- if (!response.ok) {
3617
- console.warn(`[SitePong] Push token registration failed: HTTP ${response.status}`);
3618
- }
3619
- } catch (err) {
3620
- console.warn("[SitePong] Push token registration failed:", err);
3426
+ for (const k of Object.keys(prev)) {
3427
+ if (!(k in next)) out.push(RemoveNodeAttribute(id, k));
3621
3428
  }
3429
+ return out;
3622
3430
  }
3623
- async function deleteFromIngest(path, body) {
3624
- const endpoint = getEndpoint();
3625
- const apiKey = getApiKey();
3626
- if (!apiKey) return;
3627
- try {
3628
- const response = await fetch(`${endpoint}${path}`, {
3629
- method: "DELETE",
3630
- headers: {
3631
- "Content-Type": "application/json",
3632
- "X-API-Key": apiKey
3633
- },
3634
- body: JSON.stringify(body)
3635
- });
3636
- if (!response.ok) {
3637
- console.warn(`[SitePong] Push token deactivation failed: HTTP ${response.status}`);
3638
- }
3639
- } catch (err) {
3640
- console.warn("[SitePong] Push token deactivation failed:", err);
3431
+ function diff(prev, next, mirror) {
3432
+ const { next: nextIndex, order } = indexTree(next, mirror);
3433
+ const messages = [];
3434
+ const removed = /* @__PURE__ */ new Set();
3435
+ for (const id of prev.keys()) if (!nextIndex.has(id)) removed.add(id);
3436
+ for (const id of removed) {
3437
+ const rec = prev.get(id);
3438
+ if (!removed.has(rec.parentId)) messages.push(RemoveNode(id));
3641
3439
  }
3642
- }
3643
- function registerPushToken(token, options) {
3644
- const device = getDeviceContext();
3645
- postToIngest("/api/push/tokens", {
3646
- expo_push_token: token,
3647
- environment: options.environment,
3648
- device_id: device.deviceId,
3649
- platform: device.platform,
3650
- app_version: device.appVersion,
3651
- device_model: device.deviceModel,
3652
- os_version: device.osVersion
3653
- });
3654
- }
3655
- function registerDeviceToken(token, options) {
3656
- const device = getDeviceContext();
3657
- const tokenType = options.platform === "ios" ? "apns" : "fcm";
3658
- postToIngest("/api/push/tokens", {
3659
- native_device_token: token,
3660
- token_type: tokenType,
3661
- environment: options.environment,
3662
- device_id: device.deviceId,
3663
- platform: options.platform,
3664
- app_version: device.appVersion,
3665
- device_model: device.deviceModel,
3666
- os_version: device.osVersion
3667
- });
3668
- }
3669
- function registerLiveActivityToken(activityType, activityId, pushToken, options) {
3670
- const device = getDeviceContext();
3671
- postToIngest("/api/push/live-activity-tokens", {
3672
- activity_type: activityType,
3673
- activity_id: activityId,
3674
- push_token: pushToken,
3675
- environment: options.environment,
3676
- device_id: device.deviceId
3677
- });
3678
- }
3679
- function registerPushToStartToken(activityType, pushToStartToken, options) {
3680
- const device = getDeviceContext();
3681
- postToIngest("/api/push/push-to-start-tokens", {
3682
- activity_type: activityType,
3683
- push_to_start_token: pushToStartToken,
3684
- environment: options.environment,
3685
- device_id: device.deviceId
3686
- });
3687
- }
3688
- function endLiveActivity(activityType, activityId) {
3689
- deleteFromIngest("/api/push/live-activity-tokens", {
3690
- activity_type: activityType,
3691
- activity_id: activityId
3692
- });
3693
- }
3694
- var SitePongRNContext = React2.createContext({
3695
- isInitialized: false,
3696
- performanceManager: null
3697
- });
3698
- function SitePongRNProvider({
3699
- apiKey,
3700
- config = {},
3701
- asyncStorage,
3702
- navigationRef,
3703
- trackNetwork = true,
3704
- trackAppState = true,
3705
- captureErrors = true,
3706
- screenRecording,
3707
- devicePushToken,
3708
- devicePlatform,
3709
- pushEnvironment = "production",
3710
- pushUserId,
3711
- children
3712
- }) {
3713
- const initialized = React2.useRef(false);
3714
- const [sdkReady, setSdkReady] = React2.useState(false);
3715
- const performanceManagerRef = React2.useRef(null);
3716
- React2.useEffect(() => {
3717
- if (initialized.current) return;
3718
- const teardowns = [];
3719
- const deviceInfo = collectDeviceInfo();
3720
- setRNDeviceInfo({
3721
- platform: "react-native",
3722
- osName: deviceInfo.platform,
3723
- osVersion: deviceInfo.osVersion,
3724
- deviceName: deviceInfo.deviceName,
3725
- deviceBrand: deviceInfo.deviceBrand,
3726
- deviceModel: deviceInfo.deviceModel,
3727
- appVersion: deviceInfo.appVersion,
3728
- screenWidth: deviceInfo.screenWidth,
3729
- screenHeight: deviceInfo.screenHeight,
3730
- isEmulator: deviceInfo.isEmulator
3731
- });
3732
- fetchPersistentDeviceId().then((id) => {
3733
- if (id) setNativeDeviceId(id);
3734
- }).catch(() => {
3735
- });
3736
- const storageAdapter = asyncStorage ? createAsyncStorageAdapter(asyncStorage) : void 0;
3737
- initRN({
3738
- apiKey,
3739
- ...config,
3740
- _storageAdapter: storageAdapter
3741
- });
3742
- if (captureErrors) {
3743
- const teardownErrors = setupRNErrorHandler();
3744
- teardowns.push(teardownErrors);
3745
- }
3746
- if (trackNetwork) {
3747
- const teardownNetwork = setupNetworkInterception();
3748
- teardowns.push(teardownNetwork);
3749
- }
3750
- if (trackAppState) {
3751
- const autocapture = new RNAutocaptureModule({ trackAppState: true });
3752
- autocapture.start();
3753
- teardowns.push(() => autocapture.stop());
3754
- }
3755
- if (navigationRef) {
3756
- const teardownNav = createNavigationTracker(navigationRef);
3757
- teardowns.push(teardownNav);
3758
- }
3759
- if (screenRecording?.enabled) {
3760
- const sessionId = `rn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
3761
- const endpoint = config.endpoint || "https://ingest.sitepong.com";
3762
- startScreenRecordingOnError(apiKey, endpoint, sessionId, screenRecording);
3763
- teardowns.push(() => stopScreenRecording());
3764
- }
3765
- if (devicePushToken && devicePlatform) {
3766
- registerDeviceToken(devicePushToken, { platform: devicePlatform, environment: pushEnvironment});
3767
- }
3768
- performanceManagerRef.current = new RNPerformanceManager();
3769
- performanceManagerRef.current.trackColdStart();
3770
- initialized.current = true;
3771
- setSdkReady(true);
3772
- return () => {
3773
- for (const teardown of teardowns) {
3774
- teardown();
3775
- }
3776
- };
3777
- }, [apiKey]);
3778
- const value = {
3779
- isInitialized: sdkReady,
3780
- performanceManager: performanceManagerRef.current
3781
- };
3782
- return /* @__PURE__ */ jsxRuntime.jsx(SitePongRNContext.Provider, { value, children });
3783
- }
3784
- function useSitePongRNContext() {
3785
- return React2.useContext(SitePongRNContext);
3786
- }
3787
- function useScreenTrack(screenName) {
3788
- const { performanceManager } = React2.useContext(SitePongRNContext);
3789
- React2.useEffect(() => {
3790
- if (!performanceManager) return;
3791
- performanceManager.startScreenRender(screenName);
3792
- return () => {
3793
- performanceManager.endScreenRender(screenName);
3794
- };
3795
- }, [screenName, performanceManager]);
3796
- }
3797
- function useAppState() {
3798
- const [appState, setAppState] = React2.useState("active");
3799
- React2.useEffect(() => {
3800
- setAppState(reactNative.AppState.currentState);
3801
- const subscription = reactNative.AppState.addEventListener("change", (nextState) => {
3802
- setAppState(nextState);
3803
- });
3804
- return () => subscription.remove();
3805
- }, []);
3806
- return appState;
3807
- }
3808
- function useRemoteConfig() {
3809
- const [config, setConfig] = React2.useState(() => getRemoteConfig());
3810
- React2.useEffect(() => {
3811
- setConfig(getRemoteConfig());
3812
- const unsubscribe = onRemoteConfigChange((newConfig) => {
3813
- setConfig(newConfig);
3814
- });
3815
- return unsubscribe;
3816
- }, []);
3817
- return config;
3818
- }
3819
- function useRNPerformance(screenName) {
3820
- const { performanceManager } = React2.useContext(SitePongRNContext);
3821
- const [duration, setDuration] = React2.useState(null);
3822
- const startTimeRef = React2.useRef(Date.now());
3823
- React2.useEffect(() => {
3824
- startTimeRef.current = Date.now();
3825
- const handle = requestAnimationFrame(() => {
3826
- const d = Date.now() - startTimeRef.current;
3827
- setDuration(d);
3828
- if (performanceManager) {
3829
- performanceManager.startScreenRender(screenName);
3830
- performanceManager.endScreenRender(screenName);
3440
+ for (const id of order) {
3441
+ const rec = nextIndex.get(id);
3442
+ const before = prev.get(id);
3443
+ if (!before) {
3444
+ if (rec.tag === "#text") {
3445
+ messages.push(CreateTextNode(id, rec.parentId, rec.index));
3446
+ } else {
3447
+ messages.push(CreateElementNode(id, rec.parentId, rec.index, rec.tag));
3831
3448
  }
3832
- });
3833
- return () => cancelAnimationFrame(handle);
3834
- }, [screenName, performanceManager]);
3835
- return { duration };
3449
+ for (const k of Object.keys(rec.attrs)) messages.push(SetNodeAttribute(id, k, rec.attrs[k]));
3450
+ if (rec.text !== void 0 && rec.text !== "") messages.push(SetNodeData(id, rec.text));
3451
+ } else {
3452
+ if (before.parentId !== rec.parentId || before.index !== rec.index) {
3453
+ messages.push(MoveNode(id, rec.parentId, rec.index));
3454
+ }
3455
+ messages.push(...attrDiff(id, before.attrs, rec.attrs));
3456
+ if ((before.text ?? "") !== (rec.text ?? "")) messages.push(SetNodeData(id, rec.text ?? ""));
3457
+ }
3458
+ }
3459
+ return { messages, index: nextIndex };
3836
3460
  }
3837
- var warnedMissing = false;
3838
- function loadBridge() {
3839
- return getScreenRecorderModule();
3461
+ function stableAnchor(step) {
3462
+ if (step.attrs.testID) return `@${step.attrs.testID}`;
3463
+ if (step.attrs.nativeID) return `#${step.attrs.nativeID}`;
3464
+ return null;
3840
3465
  }
3841
- var started = false;
3842
- function startWatchtower(config) {
3843
- if (started) return;
3844
- const bridge = loadBridge();
3845
- if (!bridge) {
3846
- if (!warnedMissing) {
3847
- warnedMissing = true;
3848
- console.warn(
3849
- "[SitePong] Watchtower is enabled but the native module is unavailable. Rebuild the native app (npx expo prebuild && npx expo run:ios) so the bundled SitePong native module is linked."
3850
- );
3466
+ function computeSelector(path) {
3467
+ if (path.length === 0) return "";
3468
+ const self = path[path.length - 1];
3469
+ const own = stableAnchor(self);
3470
+ if (own) return own;
3471
+ const parts = [];
3472
+ for (let i = path.length - 1; i >= 0; i--) {
3473
+ const step = path[i];
3474
+ const anchor = stableAnchor(step);
3475
+ if (anchor && i !== path.length - 1) {
3476
+ parts.unshift(anchor);
3477
+ return parts.join(" > ");
3478
+ }
3479
+ const a11y = step.attrs.accessibilityLabel;
3480
+ const comp = step.attrs.rnComponent;
3481
+ if (i === path.length - 1 && a11y) {
3482
+ parts.unshift(`${step.tag}[a11y="${truncate(a11y)}"]`);
3483
+ } else if (comp && comp !== step.tag) {
3484
+ parts.unshift(`${comp}`);
3485
+ } else {
3486
+ parts.unshift(`${step.tag}:nth-of-type(${step.nthOfType})`);
3851
3487
  }
3852
- return;
3853
3488
  }
3854
- bridge.watchtowerStart({
3855
- apiKey: config.apiKey,
3856
- projectId: config.projectId,
3857
- endpoint: config.endpoint,
3858
- sampleRate: config.sampleRate ?? 0.1,
3859
- platform: config.platform ?? "react-native-ios"
3860
- });
3861
- started = true;
3862
- if (config.distinctId) setWatchtowerUser(config.distinctId);
3489
+ return parts.join(" > ");
3863
3490
  }
3864
- function setWatchtowerUser(distinctId) {
3865
- try {
3866
- loadBridge()?.watchtowerSetUser(distinctId);
3867
- } catch {
3868
- }
3491
+ function truncate(s, n = 40) {
3492
+ return s.length > n ? s.slice(0, n) : s;
3869
3493
  }
3870
- function getWatchtowerSessionId() {
3871
- try {
3872
- return loadBridge()?.watchtowerGetSessionId() ?? null;
3873
- } catch {
3874
- return null;
3875
- }
3494
+ function computeLabel(attrs, text) {
3495
+ if (attrs.accessibilityLabel) return truncate(attrs.accessibilityLabel, 100);
3496
+ if (attrs.testID) return attrs.testID;
3497
+ if (text && text.trim()) return truncate(text.trim(), 100);
3498
+ if (attrs.rnComponent) return attrs.rnComponent;
3499
+ return "";
3876
3500
  }
3877
- function stopWatchtower() {
3878
- if (!started) return;
3879
- loadBridge()?.watchtowerStop();
3880
- started = false;
3501
+ var RAGE_WINDOW_MS = 800;
3502
+ var RAGE_MIN_COUNT = 3;
3503
+ var OUTCOME_WINDOW_MS = 1e3;
3504
+ var TOGGLE_ATTR_MAX = 4;
3505
+ var OUTCOME_LABELS = {
3506
+ navigated: "Navigated to a new screen",
3507
+ opened_overlay: "Opened a modal / overlay",
3508
+ closed_overlay: "Dismissed a modal / overlay",
3509
+ toggled: "Toggled UI state",
3510
+ content_changed: "Updated on-screen content",
3511
+ no_effect: "Nothing changed (dead tap)"
3512
+ };
3513
+ var InteractionAnalyzer = class {
3514
+ constructor() {
3515
+ __publicField(this, "activity", []);
3516
+ __publicField(this, "screenViews", []);
3517
+ __publicField(this, "taps", []);
3518
+ }
3519
+ recordActivity(a) {
3520
+ this.activity.push(a);
3521
+ }
3522
+ recordScreenView(t) {
3523
+ this.screenViews.push(t);
3524
+ }
3525
+ /** Register a tap; returns whether it is (so far) a rage tap and the count. */
3526
+ recordTap(tap) {
3527
+ this.taps.push(tap);
3528
+ const from = tap.t - RAGE_WINDOW_MS;
3529
+ const sameSel = this.taps.filter((x) => x.selector === tap.selector && x.t >= from && x.t <= tap.t);
3530
+ return { rage: sameSel.length >= RAGE_MIN_COUNT, count: sameSel.length };
3531
+ }
3532
+ /**
3533
+ * Evaluate a tap once its outcome window has closed. Classifies the post-tap
3534
+ * mutation diff into a semantic outcome and flags dead/rage.
3535
+ */
3536
+ evaluate(tap) {
3537
+ const end = tap.t + OUTCOME_WINDOW_MS;
3538
+ const win = this.activity.filter((a) => a.t > tap.t && a.t <= end);
3539
+ const navigated = this.screenViews.some((t) => t > tap.t && t <= end);
3540
+ const sum = (f) => win.reduce((n, a) => n + f(a), 0);
3541
+ const structural = sum((a) => a.create) + sum((a) => a.remove) + sum((a) => a.move);
3542
+ const attrs = sum((a) => a.attr) + sum((a) => a.text);
3543
+ const mutationCount = structural + attrs;
3544
+ const overlayAdd = win.some((a) => a.overlayAdd);
3545
+ const overlayRemove = win.some((a) => a.overlayRemove);
3546
+ let kind;
3547
+ if (navigated) kind = "navigated";
3548
+ else if (overlayAdd) kind = "opened_overlay";
3549
+ else if (overlayRemove) kind = "closed_overlay";
3550
+ else if (mutationCount === 0) kind = "no_effect";
3551
+ else if (structural === 0 && attrs <= TOGGLE_ATTR_MAX) kind = "toggled";
3552
+ else kind = "content_changed";
3553
+ const from = tap.t - RAGE_WINDOW_MS;
3554
+ const rageCount = this.taps.filter(
3555
+ (x) => x.selector === tap.selector && x.t >= from && x.t <= tap.t
3556
+ ).length;
3557
+ let frustration = null;
3558
+ if (kind === "no_effect") frustration = { kind: "dead", count: 1 };
3559
+ if (rageCount >= RAGE_MIN_COUNT) frustration = { kind: "rage", count: rageCount };
3560
+ return { frustration, outcome: { kind, label: OUTCOME_LABELS[kind], mutationCount } };
3561
+ }
3562
+ };
3563
+ var OVERLAY_TAGS = /* @__PURE__ */ new Set(["Modal"]);
3564
+ function isOverlayRecord(tag, attrs) {
3565
+ if (tag && OVERLAY_TAGS.has(tag)) return true;
3566
+ if (attrs && (attrs.wtOverlay === "true" || attrs.role === "dialog")) return true;
3567
+ return false;
3881
3568
  }
3882
- function subscribeNavigationToWatchtower(navigationRef) {
3883
- const unsubscribe = createNavigationTracker(navigationRef, {
3884
- screenNameFilter: (name) => {
3885
- try {
3886
- loadBridge()?.watchtowerSetScreen(name);
3887
- } catch {
3569
+ var Recorder = class {
3570
+ constructor() {
3571
+ __publicField(this, "mirror", new Mirror());
3572
+ __publicField(this, "index", emptyIndex());
3573
+ __publicField(this, "analyzer", new InteractionAnalyzer());
3574
+ __publicField(this, "stream", []);
3575
+ __publicField(this, "pending", []);
3576
+ __publicField(this, "viewport", { w: 0, h: 0 });
3577
+ }
3578
+ emit(t, m) {
3579
+ this.stream.push({ t, m });
3580
+ }
3581
+ setViewport(w, h, t) {
3582
+ this.viewport = { w, h };
3583
+ this.emit(t, [5, w, h]);
3584
+ }
3585
+ /** Feed a fresh tree snapshot; diffs against the last and streams mutations. */
3586
+ commit(tree, t) {
3587
+ const prev = this.index;
3588
+ const { messages, index } = diff(prev, tree, this.mirror);
3589
+ for (const m of messages) this.emit(t, m);
3590
+ const newIds = /* @__PURE__ */ new Set();
3591
+ for (const m of messages) {
3592
+ if (m[0] === 8 || m[0] === 9) newIds.add(m[1]);
3593
+ }
3594
+ let create = 0, remove = 0, move = 0, attr = 0, text = 0;
3595
+ let overlayAdd = false, overlayRemove = false;
3596
+ for (const m of messages) {
3597
+ switch (m[0]) {
3598
+ case 8:
3599
+ create++;
3600
+ if (isOverlayRecord(index.get(m[1])?.tag, index.get(m[1])?.attrs)) overlayAdd = true;
3601
+ break;
3602
+ case 9:
3603
+ create++;
3604
+ break;
3605
+ case 11:
3606
+ remove++;
3607
+ if (isOverlayRecord(prev.get(m[1])?.tag, prev.get(m[1])?.attrs)) overlayRemove = true;
3608
+ break;
3609
+ case 10:
3610
+ move++;
3611
+ break;
3612
+ case 12:
3613
+ case 13:
3614
+ if (!newIds.has(m[1])) attr++;
3615
+ break;
3616
+ case 14:
3617
+ if (!newIds.has(m[1])) text++;
3618
+ break;
3888
3619
  }
3889
- return name;
3890
3620
  }
3891
- });
3892
- try {
3893
- const route = navigationRef.getCurrentRoute?.();
3894
- if (route?.name) loadBridge()?.watchtowerSetScreen(route.name);
3895
- } catch {
3621
+ const activity = { t, create, remove, move, attr, text, overlayAdd, overlayRemove };
3622
+ this.analyzer.recordActivity(activity);
3623
+ this.index = index;
3624
+ this.drainPending(t);
3625
+ }
3626
+ tap(input, t) {
3627
+ const [id] = this.mirror.getId(input.key);
3628
+ const selector = computeSelector(input.path);
3629
+ const label = computeLabel(input.attrs, input.text);
3630
+ const nx = Math.round(input.normX * 1e4);
3631
+ const ny = Math.round(input.normY * 1e4);
3632
+ this.emit(t, MobileTap(id, input.hesitationMs ?? 0, label, selector, nx, ny));
3633
+ this.analyzer.recordTap({ t, nodeId: id, selector });
3634
+ this.pending.push({ t, nodeId: id, selector });
3635
+ this.drainPending(t);
3636
+ }
3637
+ screenView(name, prevName, t) {
3638
+ this.emit(t, ScreenView(name, prevName));
3639
+ this.analyzer.recordScreenView(t);
3640
+ this.drainPending(t);
3641
+ }
3642
+ /** Record a scroll offset for a node (absolute content offset, px). The player
3643
+ * translates the node's children by (-x,-y), reproducing the scroll/drag —
3644
+ * the RN fiber walk can't see native-driven scroll, so it's fed from gestures. */
3645
+ scroll(key, x, y, t) {
3646
+ const [id] = this.mirror.getId(key);
3647
+ this.emit(t, SetNodeScroll(id, Math.round(x), Math.round(y)));
3648
+ }
3649
+ /** Record a node scroll/offset change (from a drag/scroll gesture). */
3650
+ setNodeScroll(nodeId, scrollX, scrollY, t) {
3651
+ this.emit(t, [16, nodeId, scrollX, scrollY]);
3652
+ }
3653
+ /** Evaluate any pending taps whose outcome window has closed by time `t`. */
3654
+ drainPending(t) {
3655
+ const ready = this.pending.filter((p) => t >= p.t + OUTCOME_WINDOW_MS);
3656
+ if (!ready.length) return;
3657
+ this.pending = this.pending.filter((p) => t < p.t + OUTCOME_WINDOW_MS);
3658
+ for (const p of ready) this.evaluateTap(p);
3659
+ }
3660
+ evaluateTap(p) {
3661
+ const at = p.t + OUTCOME_WINDOW_MS;
3662
+ const v = this.analyzer.evaluate(p);
3663
+ if (v.frustration) {
3664
+ this.emit(at, Frustration(v.frustration.kind, p.nodeId, p.selector, v.frustration.count));
3665
+ }
3666
+ this.emit(at, TapOutcome(p.nodeId, v.outcome.kind, v.outcome.label, v.outcome.mutationCount));
3896
3667
  }
3897
- return unsubscribe;
3898
- }
3899
- function WatchtowerProvider(props) {
3900
- const { config, navigationRef, children } = props;
3901
- React2__default.default.useEffect(() => {
3902
- startWatchtower(config);
3903
- const unsubscribe = subscribeNavigationToWatchtower(navigationRef);
3904
- return () => {
3905
- unsubscribe();
3906
- stopWatchtower();
3907
- };
3908
- }, []);
3909
- return React2__default.default.createElement(React2__default.default.Fragment, null, children);
3910
- }
3911
-
3912
- // ../watchtower-recorder/dist/index.mjs
3913
- var CreateElementNode = (id, parentID, index, tag) => [8, id, parentID, index, tag];
3914
- var CreateTextNode = (id, parentID, index) => [
3915
- 9,
3916
- id,
3917
- parentID,
3918
- index
3919
- ];
3920
- var MoveNode = (id, parentID, index) => [
3921
- 10,
3922
- id,
3923
- parentID,
3924
- index
3925
- ];
3926
- var RemoveNode = (id) => [11, id];
3927
- var SetNodeAttribute = (id, name, value) => [
3928
- 12,
3929
- id,
3930
- name,
3931
- value
3932
- ];
3933
- var RemoveNodeAttribute = (id, name) => [
3934
- 13,
3935
- id,
3936
- name
3937
- ];
3938
- var SetNodeData = (id, data) => [14, id, data];
3939
- var MobileTap = (id, hesitationMs, label, selector, normX, normY) => [68, id, hesitationMs, label, selector, normX, normY];
3940
- var ScreenView = (name, prevName) => [
3941
- 200,
3942
- name,
3943
- prevName
3944
- ];
3945
- var Frustration = (kind, nodeId, selector, count) => [201, kind, nodeId, selector, count];
3946
- var TapOutcome = (tapNodeId, kind, label, mutationCount) => [202, tapNodeId, kind, label, mutationCount];
3947
- function walk(root, visit) {
3948
- const rec = (node, parent, index) => {
3949
- visit(node, parent, index);
3950
- node.children.forEach((child, i) => rec(child, node, i));
3951
- };
3952
- if (root) rec(root, null, 0);
3953
- }
3954
- var Mirror = class {
3955
- constructor() {
3956
- __publicField(this, "ids", /* @__PURE__ */ new WeakMap());
3957
- __publicField(this, "byId", /* @__PURE__ */ new Map());
3958
- __publicField(this, "next", 1);
3668
+ /** Explicit clock advance (adapter ticker) to close outcome windows even when
3669
+ * no commit/tap arrives. */
3670
+ advance(t) {
3671
+ this.drainPending(t);
3959
3672
  }
3960
- // 0 is reserved for "no/root parent"
3961
- /** Existing id for a node, or undefined if never registered. */
3962
- peek(key) {
3963
- return this.ids.get(key);
3673
+ /** Flush all remaining pending taps (session end). `_t` is the session-end
3674
+ * clock, accepted for API symmetry with advance(); evaluation uses each tap's
3675
+ * own outcome window. */
3676
+ finalize(_t) {
3677
+ const ready = this.pending;
3678
+ this.pending = [];
3679
+ for (const p of ready) this.evaluateTap(p);
3680
+ this.stream.sort((a, b) => a.t - b.t);
3964
3681
  }
3965
- /** Id for a node, assigning a fresh one on first sight. Second tuple element
3966
- * is true when the id was just created (→ emit a Create* message). */
3967
- getId(key) {
3968
- const existing = this.ids.get(key);
3969
- if (existing !== void 0) return [existing, false];
3970
- const id = this.next++;
3971
- this.ids.set(key, id);
3972
- this.byId.set(id, key);
3973
- return [id, true];
3682
+ getStream() {
3683
+ return [...this.stream].sort((a, b) => a.t - b.t);
3974
3684
  }
3975
- getKey(id) {
3976
- return this.byId.get(id);
3685
+ /** Total messages emitted so far (insertion order). Adapters use this as the
3686
+ * cursor for incremental delta flushing — see `sliceStream`. */
3687
+ get emittedCount() {
3688
+ return this.stream.length;
3977
3689
  }
3978
- /** Drop a node's id (on unmount). Idempotent. */
3979
- release(key) {
3980
- const id = this.ids.get(key);
3981
- if (id === void 0) return void 0;
3982
- this.ids.delete(key);
3983
- this.byId.delete(id);
3984
- return id;
3690
+ /** Messages emitted since insertion index `from`, in EMISSION order (not
3691
+ * sorted by time). This is the delta an adapter ships each flush; the cursor
3692
+ * is the count it last confirmed stored. Late-drained outcome messages (whose
3693
+ * `t` is earlier than emission time) appear here at their emission position,
3694
+ * so the server appends verbatim and the reader sorts by `t`. */
3695
+ sliceStream(from) {
3696
+ return this.stream.slice(Math.max(0, from));
3985
3697
  }
3986
- get size() {
3987
- return this.byId.size;
3698
+ get viewportSize() {
3699
+ return this.viewport;
3988
3700
  }
3989
3701
  };
3990
- var emptyIndex = () => /* @__PURE__ */ new Map();
3991
- function indexTree(tree, mirror, prev) {
3992
- const next = /* @__PURE__ */ new Map();
3993
- const order = [];
3994
- walk(tree, (node, parent, idx) => {
3995
- const [id] = mirror.getId(node.key);
3996
- const parentId = parent ? mirror.getId(parent.key)[0] : 0;
3997
- next.set(id, {
3998
- id,
3999
- parentId,
4000
- index: idx,
4001
- tag: node.tag,
4002
- attrs: node.attrs,
4003
- text: node.text
4004
- });
4005
- order.push(id);
4006
- });
4007
- return { next, order };
3702
+
3703
+ // src/react-native/structural/install-hook.ts
3704
+ var g = globalThis;
3705
+ var hookState = g.__SITEPONG_STRUCTURAL_HOOK_STATE__ ?? (g.__SITEPONG_STRUCTURAL_HOOK_STATE__ = {
3706
+ latestRoot: null,
3707
+ dirty: false
3708
+ });
3709
+ function record(root) {
3710
+ hookState.latestRoot = root;
3711
+ hookState.dirty = true;
4008
3712
  }
4009
- function attrDiff(id, prev, next) {
4010
- const out = [];
4011
- for (const k of Object.keys(next)) {
4012
- if (prev[k] !== next[k]) out.push(SetNodeAttribute(id, k, next[k]));
3713
+ function installStructuralHook() {
3714
+ if (g.__SITEPONG_STRUCTURAL_HOOK_INSTALLED__) return;
3715
+ const existing = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
3716
+ if (existing) {
3717
+ const prevRaw = existing.onCommitFiberRoot;
3718
+ const prev = typeof prevRaw === "function" ? prevRaw.bind(existing) : null;
3719
+ existing.onCommitFiberRoot = (id, root, ...rest) => {
3720
+ record(root);
3721
+ return prev ? prev(id, root, ...rest) : void 0;
3722
+ };
3723
+ } else {
3724
+ const renderers = /* @__PURE__ */ new Map();
3725
+ g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
3726
+ renderers,
3727
+ supportsFiber: true,
3728
+ inject(renderer) {
3729
+ const id = renderers.size + 1;
3730
+ renderers.set(id, renderer);
3731
+ return id;
3732
+ },
3733
+ onCommitFiberRoot(_id, root) {
3734
+ record(root);
3735
+ },
3736
+ onCommitFiberUnmount() {
3737
+ },
3738
+ onScheduleFiberRoot() {
3739
+ },
3740
+ on() {
3741
+ },
3742
+ off() {
3743
+ },
3744
+ sub() {
3745
+ return () => {
3746
+ };
3747
+ },
3748
+ checkDCE() {
3749
+ }
3750
+ };
4013
3751
  }
4014
- for (const k of Object.keys(prev)) {
4015
- if (!(k in next)) out.push(RemoveNodeAttribute(id, k));
3752
+ g.__SITEPONG_STRUCTURAL_HOOK_INSTALLED__ = true;
3753
+ }
3754
+ var HostComponent = 5;
3755
+ var HostText = 6;
3756
+ function mapTag(type) {
3757
+ if (typeof type !== "string") return "View";
3758
+ const t = type;
3759
+ if (/TextInput|TextField/i.test(t)) return "TextInput";
3760
+ if (/Image/i.test(t)) return "Image";
3761
+ if (/ScrollView/i.test(t)) return "ScrollView";
3762
+ if (/Modal/i.test(t)) return "Modal";
3763
+ if (/Text|RawText/i.test(t)) return "Text";
3764
+ if (/Pressable|Button|Touchable/i.test(t)) return "Pressable";
3765
+ return "View";
3766
+ }
3767
+ function nativeTagOf(stateNode) {
3768
+ if (!stateNode) return void 0;
3769
+ return stateNode.__nativeTag ?? stateNode._nativeTag ?? stateNode.canonical?.nativeTag ?? stateNode.canonical?._nativeTag ?? (typeof stateNode === "number" ? stateNode : void 0);
3770
+ }
3771
+ var STYLE_KEYS = /* @__PURE__ */ new Set([
3772
+ "flex",
3773
+ "flexDirection",
3774
+ "flexGrow",
3775
+ "flexShrink",
3776
+ "flexWrap",
3777
+ "alignItems",
3778
+ "alignSelf",
3779
+ "justifyContent",
3780
+ "width",
3781
+ "height",
3782
+ "minWidth",
3783
+ "minHeight",
3784
+ "maxWidth",
3785
+ "maxHeight",
3786
+ "margin",
3787
+ "marginTop",
3788
+ "marginBottom",
3789
+ "marginLeft",
3790
+ "marginRight",
3791
+ "marginHorizontal",
3792
+ "marginVertical",
3793
+ "padding",
3794
+ "paddingTop",
3795
+ "paddingBottom",
3796
+ "paddingLeft",
3797
+ "paddingRight",
3798
+ "paddingHorizontal",
3799
+ "paddingVertical",
3800
+ "backgroundColor",
3801
+ "borderRadius",
3802
+ "borderWidth",
3803
+ "borderColor",
3804
+ "opacity",
3805
+ "position",
3806
+ "top",
3807
+ "left",
3808
+ "right",
3809
+ "bottom",
3810
+ "color",
3811
+ "fontSize",
3812
+ "fontWeight",
3813
+ "textAlign",
3814
+ "lineHeight",
3815
+ "gap",
3816
+ "overflow",
3817
+ "shadowColor",
3818
+ "shadowOpacity",
3819
+ "shadowRadius",
3820
+ "elevation",
3821
+ "transform",
3822
+ "zIndex",
3823
+ "aspectRatio"
3824
+ ]);
3825
+ function extractStyle(style) {
3826
+ try {
3827
+ const flat = reactNative.StyleSheet.flatten(style);
3828
+ if (!flat) return void 0;
3829
+ const out = {};
3830
+ for (const k of Object.keys(flat)) if (STYLE_KEYS.has(k)) out[k] = flat[k];
3831
+ return Object.keys(out).length ? JSON.stringify(out) : void 0;
3832
+ } catch {
3833
+ return void 0;
4016
3834
  }
4017
- return out;
4018
3835
  }
4019
- function diff(prev, next, mirror) {
4020
- const { next: nextIndex, order } = indexTree(next, mirror);
4021
- const messages = [];
4022
- const removed = /* @__PURE__ */ new Set();
4023
- for (const id of prev.keys()) if (!nextIndex.has(id)) removed.add(id);
4024
- for (const id of removed) {
4025
- const rec = prev.get(id);
4026
- if (!removed.has(rec.parentId)) messages.push(RemoveNode(id));
3836
+ function extractAttrs(tag, props, componentName2) {
3837
+ const attrs = {};
3838
+ if (!props) return attrs;
3839
+ if (props.testID) attrs.testID = String(props.testID);
3840
+ if (props.nativeID) attrs.nativeID = String(props.nativeID);
3841
+ if (props.accessibilityLabel) attrs.accessibilityLabel = String(props.accessibilityLabel);
3842
+ if (props.accessibilityRole) attrs.role = String(props.accessibilityRole);
3843
+ if (props["aria-label"]) attrs.accessibilityLabel = String(props["aria-label"]);
3844
+ const style = extractStyle(props.style);
3845
+ if (style) attrs.style = style;
3846
+ if (tag === "Image") {
3847
+ const uri = props.source?.uri ?? (typeof props.source === "string" ? props.source : void 0);
3848
+ if (uri) attrs.src = String(uri);
4027
3849
  }
4028
- for (const id of order) {
4029
- const rec = nextIndex.get(id);
4030
- const before = prev.get(id);
4031
- if (!before) {
4032
- if (rec.tag === "#text") {
4033
- messages.push(CreateTextNode(id, rec.parentId, rec.index));
4034
- } else {
4035
- messages.push(CreateElementNode(id, rec.parentId, rec.index, rec.tag));
4036
- }
4037
- for (const k of Object.keys(rec.attrs)) messages.push(SetNodeAttribute(id, k, rec.attrs[k]));
4038
- if (rec.text !== void 0 && rec.text !== "") messages.push(SetNodeData(id, rec.text));
4039
- } else {
4040
- if (before.parentId !== rec.parentId || before.index !== rec.index) {
4041
- messages.push(MoveNode(id, rec.parentId, rec.index));
4042
- }
4043
- messages.push(...attrDiff(id, before.attrs, rec.attrs));
4044
- if ((before.text ?? "") !== (rec.text ?? "")) messages.push(SetNodeData(id, rec.text ?? ""));
3850
+ if (componentName2) attrs.rnComponent = componentName2;
3851
+ if (props.wtOverlay || props.accessibilityViewIsModal) attrs.wtOverlay = "true";
3852
+ if (props.wtSensitive || props.secureTextEntry) attrs.wtSensitive = "true";
3853
+ return attrs;
3854
+ }
3855
+ function collectText(fiber) {
3856
+ let out = "";
3857
+ const visit = (f) => {
3858
+ if (!f) return;
3859
+ if (f.tag === HostText) {
3860
+ const p = f.memoizedProps;
3861
+ if (typeof p === "string") out += p;
3862
+ else if (p && typeof p.text === "string") out += p.text;
3863
+ } else if (f.tag === HostComponent && typeof f.type === "string" && /RawText|VirtualText/i.test(f.type)) {
3864
+ const p = f.memoizedProps;
3865
+ if (p && typeof p.text === "string") out += p.text;
4045
3866
  }
3867
+ visit(f.child);
3868
+ visit(f.sibling);
3869
+ };
3870
+ visit(fiber.child);
3871
+ return out;
3872
+ }
3873
+ function buildHostChildren(fiber, inheritedName, index, path, scrollTag) {
3874
+ const out = [];
3875
+ let f = fiber;
3876
+ while (f) {
3877
+ collectFrom(f, inheritedName, out, index, path, scrollTag);
3878
+ f = f.sibling;
4046
3879
  }
4047
- return { messages, index: nextIndex };
3880
+ return out;
4048
3881
  }
4049
- function stableAnchor(step) {
4050
- if (step.attrs.testID) return `@${step.attrs.testID}`;
4051
- if (step.attrs.nativeID) return `#${step.attrs.nativeID}`;
4052
- return null;
3882
+ function componentName(fiber) {
3883
+ const t = fiber?.type;
3884
+ if (typeof t === "function") return t.displayName || t.name || void 0;
3885
+ if (t && typeof t === "object") return t.displayName || void 0;
3886
+ return void 0;
4053
3887
  }
4054
- function computeSelector(path) {
4055
- if (path.length === 0) return "";
4056
- const self = path[path.length - 1];
4057
- const own = stableAnchor(self);
4058
- if (own) return own;
4059
- const parts = [];
4060
- for (let i = path.length - 1; i >= 0; i--) {
4061
- const step = path[i];
4062
- const anchor = stableAnchor(step);
4063
- if (anchor && i !== path.length - 1) {
4064
- parts.unshift(anchor);
4065
- return parts.join(" > ");
4066
- }
4067
- const a11y = step.attrs.accessibilityLabel;
4068
- const comp = step.attrs.rnComponent;
4069
- if (i === path.length - 1 && a11y) {
4070
- parts.unshift(`${step.tag}[a11y="${truncate(a11y)}"]`);
4071
- } else if (comp && comp !== step.tag) {
4072
- parts.unshift(`${comp}`);
4073
- } else {
4074
- parts.unshift(`${step.tag}:nth-of-type(${step.nthOfType})`);
4075
- }
3888
+ function collectFrom(fiber, inheritedName, out, index, parentPath, scrollTag) {
3889
+ if (!fiber) return;
3890
+ if (fiber.tag === HostComponent) {
3891
+ const tag = mapTag(fiber.type);
3892
+ const attrs = extractAttrs(tag, fiber.memoizedProps, inheritedName);
3893
+ const stateNode = fiber.stateNode;
3894
+ const key = stateNode && typeof stateNode === "object" ? stateNode : fiber;
3895
+ const isTextLeaf = tag === "Text";
3896
+ const text = isTextLeaf ? collectText(fiber) : void 0;
3897
+ const nthOfType = out.filter((n) => n.tag === tag).length + 1;
3898
+ const myPath = [...parentPath, { tag, attrs, nthOfType }];
3899
+ const nt = nativeTagOf(stateNode);
3900
+ const childScrollTag = tag === "ScrollView" && nt !== void 0 ? nt : scrollTag;
3901
+ const children = isTextLeaf ? [] : buildHostChildren(fiber.child, void 0, index, myPath, childScrollTag);
3902
+ const vnode = { key, tag, attrs, children, ...text ? { text } : {} };
3903
+ out.push(vnode);
3904
+ if (nt !== void 0) index.set(nt, { node: vnode, path: myPath, scrollAncestorTag: scrollTag });
3905
+ return;
3906
+ }
3907
+ const name = componentName(fiber) ?? inheritedName;
3908
+ let child = fiber.child;
3909
+ while (child) {
3910
+ collectFrom(child, name, out, index, parentPath, scrollTag);
3911
+ child = child.sibling;
4076
3912
  }
4077
- return parts.join(" > ");
4078
3913
  }
4079
- function truncate(s, n = 40) {
4080
- return s.length > n ? s.slice(0, n) : s;
3914
+ function walkFiberRoot(root) {
3915
+ const index = /* @__PURE__ */ new Map();
3916
+ const current = root?.current;
3917
+ if (!current) return { tree: null, index };
3918
+ const rootKey = root ?? current;
3919
+ const children = buildHostChildren(current.child, void 0, index, [], void 0);
3920
+ const tree = { key: rootKey, tag: "View", attrs: { style: JSON.stringify({ flex: 1 }) }, children };
3921
+ return { tree, index };
4081
3922
  }
4082
- function computeLabel(attrs, text) {
4083
- if (attrs.accessibilityLabel) return truncate(attrs.accessibilityLabel, 100);
4084
- if (attrs.testID) return attrs.testID;
4085
- if (text && text.trim()) return truncate(text.trim(), 100);
4086
- if (attrs.rnComponent) return attrs.rnComponent;
4087
- return "";
3923
+ function fiberRootFromInstance(instance) {
3924
+ if (!instance || typeof instance !== "object") return null;
3925
+ const inst = instance;
3926
+ const fiber = inst.__internalInstanceHandle ?? inst._internalFiberInstanceHandleDEV ?? null;
3927
+ if (!fiber || typeof fiber !== "object") return null;
3928
+ let top = fiber;
3929
+ for (let guard = 0; top.return && guard < 1e5; guard++) top = top.return;
3930
+ const fiberRoot = top.stateNode;
3931
+ return fiberRoot && typeof fiberRoot === "object" && fiberRoot.current ? fiberRoot : null;
4088
3932
  }
4089
- var RAGE_WINDOW_MS = 800;
4090
- var RAGE_MIN_COUNT = 3;
4091
- var OUTCOME_WINDOW_MS = 1e3;
4092
- var TOGGLE_ATTR_MAX = 4;
4093
- var OUTCOME_LABELS = {
4094
- navigated: "Navigated to a new screen",
4095
- opened_overlay: "Opened a modal / overlay",
4096
- closed_overlay: "Dismissed a modal / overlay",
4097
- toggled: "Toggled UI state",
4098
- content_changed: "Updated on-screen content",
4099
- no_effect: "Nothing changed (dead tap)"
4100
- };
4101
- var InteractionAnalyzer = class {
3933
+
3934
+ // src/react-native/structural/capture.ts
3935
+ var WALK_BUDGET_MS = 24;
3936
+ var MAX_OVERLOAD_SKIP = 8;
3937
+ function genSessionId() {
3938
+ const rand = Math.floor(Math.random() * 16777215).toString(36);
3939
+ return `wt-${Date.now().toString(36)}-${rand}`;
3940
+ }
3941
+ var StructuralCapture = class {
4102
3942
  constructor() {
4103
- __publicField(this, "activity", []);
4104
- __publicField(this, "screenViews", []);
4105
- __publicField(this, "taps", []);
3943
+ this.recorder = new Recorder();
3944
+ this.currentIndex = /* @__PURE__ */ new Map();
3945
+ this.started = 0;
3946
+ this.sampleTimer = null;
3947
+ this.flushTimer = null;
3948
+ this.screen = "";
3949
+ this.prevScreen = "";
3950
+ this.cfg = null;
3951
+ this.sessionId = "";
3952
+ this.running = false;
3953
+ // Incremental delivery: cursor into the recorder's emission stream that has
3954
+ // been CONFIRMED stored. Everything at index >= sentCount is unsent (the
3955
+ // offline buffer). Advances only on a 2xx.
3956
+ this.sentCount = 0;
3957
+ this.flushing = false;
3958
+ // Persistent per-ScrollView content offset (keyed by native tag), so scroll
3959
+ // continues from where it was rather than resetting each gesture.
3960
+ this.scrollOffsets = /* @__PURE__ */ new Map();
3961
+ // Adaptive sampling: skip this many upcoming ticks after a heavy walk.
3962
+ this.overloadSkip = 0;
3963
+ // FiberRoot acquired from the provider's root-View ref (fiber.ts). This is the
3964
+ // production path: Release builds strip the React DevTools commit hook, so
3965
+ // `hookState.latestRoot` never populates and hook-based capture emits only the
3966
+ // initial viewport message. The FiberRoot persists for the app lifetime and
3967
+ // walkFiberRoot reads its live `.current` each sample, so we hold it here and
3968
+ // walk every tick (no `dirty` signal exists without the hook).
3969
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3970
+ this.explicitRoot = null;
3971
+ this.appStateSub = null;
3972
+ this.teardowns = [];
4106
3973
  }
4107
- recordActivity(a) {
4108
- this.activity.push(a);
3974
+ now() {
3975
+ return Date.now() - this.started;
4109
3976
  }
4110
- recordScreenView(t) {
4111
- this.screenViews.push(t);
3977
+ isRunning() {
3978
+ return this.running;
4112
3979
  }
4113
- /** Register a tap; returns whether it is (so far) a rage tap and the count. */
4114
- recordTap(tap) {
4115
- this.taps.push(tap);
4116
- const from = tap.t - RAGE_WINDOW_MS;
4117
- const sameSel = this.taps.filter((x) => x.selector === tap.selector && x.t >= from && x.t <= tap.t);
4118
- return { rage: sameSel.length >= RAGE_MIN_COUNT, count: sameSel.length };
3980
+ getSessionId() {
3981
+ return this.sessionId;
3982
+ }
3983
+ start(cfg) {
3984
+ if (this.running) return;
3985
+ installStructuralHook();
3986
+ this.cfg = cfg;
3987
+ this.sessionId = cfg.sessionId || genSessionId();
3988
+ this.started = Date.now();
3989
+ this.running = true;
3990
+ this.sentCount = 0;
3991
+ this.scrollOffsets.clear();
3992
+ this.overloadSkip = 0;
3993
+ this.explicitRoot = null;
3994
+ const { width, height } = reactNative.Dimensions.get("window");
3995
+ this.recorder.setViewport(Math.round(width), Math.round(height), 0);
3996
+ this.sampleTimer = setInterval(() => this.sample(), cfg.sampleIntervalMs ?? 120);
3997
+ this.flushTimer = setInterval(() => void this.flush(), cfg.flushIntervalMs ?? 4e3);
3998
+ this.installCrashFlush();
3999
+ this.installBackgroundFlush();
4000
+ if (cfg.debug) console.log("[structural] started", this.sessionId);
4001
+ setTimeout(() => this.sample(true), 0);
4002
+ }
4003
+ /** Best-effort flush the pre-crash tail before the app tears down. */
4004
+ installCrashFlush() {
4005
+ if (typeof ErrorUtils === "undefined" || typeof ErrorUtils.setGlobalHandler !== "function") return;
4006
+ const prev = ErrorUtils.getGlobalHandler?.();
4007
+ ErrorUtils.setGlobalHandler((error, isFatal) => {
4008
+ void this.flush();
4009
+ if (typeof prev === "function") prev(error, isFatal);
4010
+ });
4011
+ this.teardowns.push(() => {
4012
+ if (typeof prev === "function") ErrorUtils.setGlobalHandler(prev);
4013
+ });
4014
+ }
4015
+ /** Flush on background/inactive so timer suspension doesn't strand the tail. */
4016
+ installBackgroundFlush() {
4017
+ const onChange = (state) => {
4018
+ if (state === "background" || state === "inactive") void this.flush();
4019
+ };
4020
+ this.appStateSub = reactNative.AppState.addEventListener("change", onChange);
4021
+ this.teardowns.push(() => {
4022
+ this.appStateSub?.remove();
4023
+ this.appStateSub = null;
4024
+ });
4119
4025
  }
4120
4026
  /**
4121
- * Evaluate a tap once its outcome window has closed. Classifies the post-tap
4122
- * mutation diff into a semantic outcome and flags dead/rage.
4027
+ * Acquire the FiberRoot from the provider's root-View public instance (a ref).
4028
+ * Called once on provider mount. This makes capture independent of the React
4029
+ * DevTools commit hook, which Release builds strip — without it the fiber walk
4030
+ * never runs in production and only the initial viewport message is emitted.
4123
4031
  */
4124
- evaluate(tap) {
4125
- const end = tap.t + OUTCOME_WINDOW_MS;
4126
- const win = this.activity.filter((a) => a.t > tap.t && a.t <= end);
4127
- const navigated = this.screenViews.some((t) => t > tap.t && t <= end);
4128
- const sum = (f) => win.reduce((n, a) => n + f(a), 0);
4129
- const structural = sum((a) => a.create) + sum((a) => a.remove) + sum((a) => a.move);
4130
- const attrs = sum((a) => a.attr) + sum((a) => a.text);
4131
- const mutationCount = structural + attrs;
4132
- const overlayAdd = win.some((a) => a.overlayAdd);
4133
- const overlayRemove = win.some((a) => a.overlayRemove);
4134
- let kind;
4135
- if (navigated) kind = "navigated";
4136
- else if (overlayAdd) kind = "opened_overlay";
4137
- else if (overlayRemove) kind = "closed_overlay";
4138
- else if (mutationCount === 0) kind = "no_effect";
4139
- else if (structural === 0 && attrs <= TOGGLE_ATTR_MAX) kind = "toggled";
4140
- else kind = "content_changed";
4141
- const from = tap.t - RAGE_WINDOW_MS;
4142
- const rageCount = this.taps.filter(
4143
- (x) => x.selector === tap.selector && x.t >= from && x.t <= tap.t
4144
- ).length;
4145
- let frustration = null;
4146
- if (kind === "no_effect") frustration = { kind: "dead", count: 1 };
4147
- if (rageCount >= RAGE_MIN_COUNT) frustration = { kind: "rage", count: rageCount };
4148
- return { frustration, outcome: { kind, label: OUTCOME_LABELS[kind], mutationCount } };
4032
+ setRootInstance(instance) {
4033
+ const root = fiberRootFromInstance(instance);
4034
+ if (root) {
4035
+ this.explicitRoot = root;
4036
+ if (this.cfg?.debug) console.log("[structural] fiber root acquired via ref");
4037
+ this.sample(true);
4038
+ } else if (this.cfg?.debug) {
4039
+ console.log("[structural] ref did not yield a fiber root \u2014 relying on the commit hook (dev only)");
4040
+ }
4149
4041
  }
4150
- };
4151
- var OVERLAY_TAGS = /* @__PURE__ */ new Set(["Modal"]);
4152
- function isOverlayRecord(tag, attrs) {
4153
- if (tag && OVERLAY_TAGS.has(tag)) return true;
4154
- if (attrs && (attrs.wtOverlay === "true" || attrs.role === "dialog")) return true;
4155
- return false;
4156
- }
4157
- var Recorder = class {
4158
- constructor() {
4159
- __publicField(this, "mirror", new Mirror());
4160
- __publicField(this, "index", emptyIndex());
4161
- __publicField(this, "analyzer", new InteractionAnalyzer());
4162
- __publicField(this, "stream", []);
4163
- __publicField(this, "pending", []);
4164
- __publicField(this, "viewport", { w: 0, h: 0 });
4042
+ sample(force = false) {
4043
+ if (!this.running) return;
4044
+ if (!force && this.overloadSkip > 0) {
4045
+ this.overloadSkip--;
4046
+ return;
4047
+ }
4048
+ if (!force && !this.explicitRoot && !hookState.dirty) return;
4049
+ hookState.dirty = false;
4050
+ const root = hookState.latestRoot ?? this.explicitRoot;
4051
+ if (!root) return;
4052
+ const t0 = Date.now();
4053
+ try {
4054
+ const { tree, index } = walkFiberRoot(root);
4055
+ this.currentIndex = index;
4056
+ this.recorder.commit(tree, this.now());
4057
+ } catch (e) {
4058
+ if (this.cfg?.debug) console.log("[structural] sample error", String(e));
4059
+ }
4060
+ const cost = Date.now() - t0;
4061
+ if (cost > WALK_BUDGET_MS) {
4062
+ this.overloadSkip = Math.min(MAX_OVERLOAD_SKIP, Math.ceil(cost / WALK_BUDGET_MS));
4063
+ }
4165
4064
  }
4166
- emit(t, m) {
4167
- this.stream.push({ t, m });
4065
+ setScreen(name) {
4066
+ if (!this.running || name === this.screen) return;
4067
+ this.prevScreen = this.screen;
4068
+ this.screen = name;
4069
+ this.sample(true);
4070
+ this.recorder.screenView(name, this.prevScreen, this.now());
4168
4071
  }
4169
- setViewport(w, h, t) {
4170
- this.viewport = { w, h };
4171
- this.emit(t, [5, w, h]);
4072
+ /** Resolve a native touch to a node + emit a tap. `target` is the RN
4073
+ * nativeTag from `nativeEvent.target`. */
4074
+ handleTap(target, pageX, pageY) {
4075
+ if (!this.running) return;
4076
+ const entry = this.currentIndex.get(target);
4077
+ if (!entry) return;
4078
+ const { width, height } = reactNative.Dimensions.get("window");
4079
+ this.recorder.tap(
4080
+ {
4081
+ key: entry.node.key,
4082
+ path: entry.path,
4083
+ attrs: entry.node.attrs,
4084
+ text: entry.node.text,
4085
+ normX: pageX / (width || 1),
4086
+ normY: pageY / (height || 1)
4087
+ },
4088
+ this.now()
4089
+ );
4172
4090
  }
4173
- /** Feed a fresh tree snapshot; diffs against the last and streams mutations. */
4174
- commit(tree, t) {
4175
- const prev = this.index;
4176
- const { messages, index } = diff(prev, tree, this.mirror);
4177
- for (const m of messages) this.emit(t, m);
4178
- const newIds = /* @__PURE__ */ new Set();
4179
- for (const m of messages) {
4180
- if (m[0] === 8 || m[0] === 9) newIds.add(m[1]);
4091
+ /** Feed a scroll/drag delta from a touch onto the nearest scrollable ancestor
4092
+ * of the touched node. `target` is the RN nativeTag under the finger; `dx`/`dy`
4093
+ * are content-offset deltas (px, sign = content motion). Native-driven scroll
4094
+ * never reaches the fiber walk, so this is the only way the replay can ride the
4095
+ * scroll instead of freezing on a stale frame. */
4096
+ handleScroll(target, dx, dy) {
4097
+ if (!this.running) return;
4098
+ const leaf = this.currentIndex.get(target);
4099
+ const scrollTag = leaf?.scrollAncestorTag;
4100
+ if (scrollTag === void 0) return;
4101
+ const scrollable = this.currentIndex.get(scrollTag);
4102
+ if (!scrollable) return;
4103
+ const cur = this.scrollOffsets.get(scrollTag) ?? { x: 0, y: 0 };
4104
+ const next = { x: Math.max(0, cur.x + dx), y: Math.max(0, cur.y + dy) };
4105
+ if (Math.abs(next.x - cur.x) < 1 && Math.abs(next.y - cur.y) < 1) return;
4106
+ this.scrollOffsets.set(scrollTag, next);
4107
+ this.recorder.scroll(scrollable.node.key, next.x, next.y, this.now());
4108
+ }
4109
+ /** Public best-effort flush — used by the shared RN error handler to push the
4110
+ * pre-crash tail (the in-class ErrorUtils hook covers apps that don't call
4111
+ * setupRNErrorHandler; both are idempotent via the `flushing` guard). */
4112
+ async flushNow() {
4113
+ await this.flush();
4114
+ }
4115
+ async flush() {
4116
+ if (!this.cfg || this.flushing) return;
4117
+ this.recorder.advance(this.now());
4118
+ const from = this.sentCount;
4119
+ const batch = this.recorder.sliceStream(from);
4120
+ if (batch.length === 0) return;
4121
+ this.flushing = true;
4122
+ const vp = this.recorder.viewportSize;
4123
+ try {
4124
+ const headers = {
4125
+ "content-type": "application/json",
4126
+ "X-API-Key": this.cfg.apiKey
4127
+ };
4128
+ if (this.cfg.projectId) headers["X-Project-ID"] = this.cfg.projectId;
4129
+ const res = await fetch(`${this.cfg.endpoint.replace(/\/$/, "")}/api/replay-stream`, {
4130
+ method: "POST",
4131
+ headers,
4132
+ body: JSON.stringify({
4133
+ session_id: this.sessionId,
4134
+ platform: this.cfg.platform,
4135
+ viewport: { w: vp.w, h: vp.h },
4136
+ seq_start: from,
4137
+ messages: batch
4138
+ })
4139
+ });
4140
+ if (res.ok) {
4141
+ this.sentCount = from + batch.length;
4142
+ if (this.cfg.debug) console.log("[structural] flushed", batch.length, "msgs @", from);
4143
+ } else if (this.cfg.debug) {
4144
+ console.log("[structural] flush rejected", res.status, "\u2014 will retry");
4145
+ }
4146
+ } catch (e) {
4147
+ if (this.cfg.debug) console.log("[structural] flush failed", String(e), "\u2014 buffered");
4148
+ } finally {
4149
+ this.flushing = false;
4181
4150
  }
4182
- let create = 0, remove = 0, move = 0, attr = 0, text = 0;
4183
- let overlayAdd = false, overlayRemove = false;
4184
- for (const m of messages) {
4185
- switch (m[0]) {
4186
- case 8:
4187
- create++;
4188
- if (isOverlayRecord(index.get(m[1])?.tag, index.get(m[1])?.attrs)) overlayAdd = true;
4189
- break;
4190
- case 9:
4191
- create++;
4192
- break;
4193
- case 11:
4194
- remove++;
4195
- if (isOverlayRecord(prev.get(m[1])?.tag, prev.get(m[1])?.attrs)) overlayRemove = true;
4196
- break;
4197
- case 10:
4198
- move++;
4199
- break;
4200
- case 12:
4201
- case 13:
4202
- if (!newIds.has(m[1])) attr++;
4203
- break;
4204
- case 14:
4205
- if (!newIds.has(m[1])) text++;
4206
- break;
4151
+ }
4152
+ async stop() {
4153
+ if (!this.running) return;
4154
+ this.recorder.finalize(this.now());
4155
+ if (this.sampleTimer) clearInterval(this.sampleTimer);
4156
+ if (this.flushTimer) clearInterval(this.flushTimer);
4157
+ this.sampleTimer = null;
4158
+ this.flushTimer = null;
4159
+ this.running = false;
4160
+ this.explicitRoot = null;
4161
+ for (const t of this.teardowns.splice(0)) {
4162
+ try {
4163
+ t();
4164
+ } catch {
4207
4165
  }
4208
4166
  }
4209
- const activity = { t, create, remove, move, attr, text, overlayAdd, overlayRemove };
4210
- this.analyzer.recordActivity(activity);
4211
- this.index = index;
4212
- this.drainPending(t);
4167
+ await this.flush();
4213
4168
  }
4214
- tap(input, t) {
4215
- const [id] = this.mirror.getId(input.key);
4216
- const selector = computeSelector(input.path);
4217
- const label = computeLabel(input.attrs, input.text);
4218
- const nx = Math.round(input.normX * 1e4);
4219
- const ny = Math.round(input.normY * 1e4);
4220
- this.emit(t, MobileTap(id, input.hesitationMs ?? 0, label, selector, nx, ny));
4221
- this.analyzer.recordTap({ t, nodeId: id, selector });
4222
- this.pending.push({ t, nodeId: id, selector });
4223
- this.drainPending(t);
4169
+ };
4170
+ var structuralCapture = new StructuralCapture();
4171
+
4172
+ // src/react-native/error-handler.ts
4173
+ function setupRNErrorHandler(options = {}) {
4174
+ const {
4175
+ captureFatal = true,
4176
+ captureNonFatal = true,
4177
+ capturePromiseRejections = true
4178
+ } = options;
4179
+ const teardowns = [];
4180
+ if (typeof ErrorUtils !== "undefined") {
4181
+ const previousHandler = ErrorUtils.getGlobalHandler();
4182
+ ErrorUtils.setGlobalHandler((error, isFatal) => {
4183
+ if (isFatal && captureFatal || !isFatal && captureNonFatal) {
4184
+ captureError(error, {
4185
+ tags: {
4186
+ fatal: String(isFatal),
4187
+ handler: "react-native-global"
4188
+ }
4189
+ });
4190
+ flushScreenRecording().catch(() => {
4191
+ });
4192
+ if (structuralCapture.isRunning()) {
4193
+ void structuralCapture.flushNow().catch(() => {
4194
+ });
4195
+ }
4196
+ }
4197
+ if (previousHandler) {
4198
+ previousHandler(error, isFatal);
4199
+ }
4200
+ });
4201
+ teardowns.push(() => {
4202
+ if (previousHandler) {
4203
+ ErrorUtils.setGlobalHandler(previousHandler);
4204
+ }
4205
+ });
4224
4206
  }
4225
- screenView(name, prevName, t) {
4226
- this.emit(t, ScreenView(name, prevName));
4227
- this.analyzer.recordScreenView(t);
4228
- this.drainPending(t);
4207
+ if (capturePromiseRejections) {
4208
+ if (typeof global !== "undefined") {
4209
+ const rejectionHandler = (event) => {
4210
+ const err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
4211
+ captureError(err, {
4212
+ tags: { handler: "unhandled-promise-rejection" }
4213
+ });
4214
+ flushScreenRecording().catch(() => {
4215
+ });
4216
+ if (structuralCapture.isRunning()) {
4217
+ void structuralCapture.flushNow().catch(() => {
4218
+ });
4219
+ }
4220
+ };
4221
+ if (typeof globalThis.addEventListener === "function") {
4222
+ globalThis.addEventListener("unhandledrejection", rejectionHandler);
4223
+ teardowns.push(() => {
4224
+ globalThis.removeEventListener("unhandledrejection", rejectionHandler);
4225
+ });
4226
+ }
4227
+ }
4229
4228
  }
4230
- /** Evaluate any pending taps whose outcome window has closed by time `t`. */
4231
- drainPending(t) {
4232
- const ready = this.pending.filter((p) => t >= p.t + OUTCOME_WINDOW_MS);
4233
- if (!ready.length) return;
4234
- this.pending = this.pending.filter((p) => t < p.t + OUTCOME_WINDOW_MS);
4235
- for (const p of ready) this.evaluateTap(p);
4229
+ return () => {
4230
+ for (const teardown of teardowns) {
4231
+ teardown();
4232
+ }
4233
+ };
4234
+ }
4235
+
4236
+ // src/react-native/navigation.ts
4237
+ function createNavigationTracker(navigationRef, options = {}) {
4238
+ const {
4239
+ trackScreenViews = true,
4240
+ addBreadcrumbs = true,
4241
+ screenNameFilter
4242
+ } = options;
4243
+ let currentRouteName;
4244
+ const unsubscribe = navigationRef.addListener("state", () => {
4245
+ const route = navigationRef.getCurrentRoute();
4246
+ if (!route) return;
4247
+ const previousRouteName = currentRouteName;
4248
+ currentRouteName = route.name;
4249
+ if (previousRouteName === currentRouteName) return;
4250
+ const screenName = screenNameFilter ? screenNameFilter(currentRouteName) : currentRouteName;
4251
+ if (!screenName) return;
4252
+ setCurrentScreen(screenName);
4253
+ if (trackScreenViews) {
4254
+ track("$screen_view", {
4255
+ screen_name: screenName,
4256
+ previous_screen: previousRouteName,
4257
+ params: route.params
4258
+ });
4259
+ }
4260
+ if (addBreadcrumbs) {
4261
+ addBreadcrumb({
4262
+ type: "navigation",
4263
+ category: "screen",
4264
+ message: `Navigated to ${screenName}`,
4265
+ data: {
4266
+ from: previousRouteName,
4267
+ to: screenName
4268
+ }
4269
+ });
4270
+ }
4271
+ });
4272
+ return () => {
4273
+ unsubscribe();
4274
+ setCurrentScreen(void 0);
4275
+ };
4276
+ }
4277
+
4278
+ // src/react-native/network.ts
4279
+ function setupNetworkInterception(options = {}) {
4280
+ const {
4281
+ addBreadcrumbs = true,
4282
+ ignoreUrls = [/sitepong\.com/],
4283
+ maxDataSize = 1024
4284
+ } = options;
4285
+ if (typeof XMLHttpRequest === "undefined") {
4286
+ return () => {
4287
+ };
4236
4288
  }
4237
- evaluateTap(p) {
4238
- const at = p.t + OUTCOME_WINDOW_MS;
4239
- const v = this.analyzer.evaluate(p);
4240
- if (v.frustration) {
4241
- this.emit(at, Frustration(v.frustration.kind, p.nodeId, p.selector, v.frustration.count));
4289
+ const originalOpen = XMLHttpRequest.prototype.open;
4290
+ const originalSend = XMLHttpRequest.prototype.send;
4291
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
4292
+ const xhr = this;
4293
+ xhr._sitepong_method = method;
4294
+ xhr._sitepong_url = String(url);
4295
+ return originalOpen.apply(this, [method, url, ...rest]);
4296
+ };
4297
+ XMLHttpRequest.prototype.send = function(body) {
4298
+ const xhr = this;
4299
+ const url = xhr._sitepong_url || "";
4300
+ const method = xhr._sitepong_method || "GET";
4301
+ const shouldIgnore = ignoreUrls.some((pattern) => {
4302
+ if (typeof pattern === "string") return url.includes(pattern);
4303
+ return pattern.test(url);
4304
+ });
4305
+ if (shouldIgnore) {
4306
+ return originalSend.call(this, body);
4242
4307
  }
4243
- this.emit(at, TapOutcome(p.nodeId, v.outcome.kind, v.outcome.label, v.outcome.mutationCount));
4244
- }
4245
- /** Explicit clock advance (adapter ticker) to close outcome windows even when
4246
- * no commit/tap arrives. */
4247
- advance(t) {
4248
- this.drainPending(t);
4308
+ xhr._sitepong_startTime = Date.now();
4309
+ const onLoadEnd = () => {
4310
+ const duration = xhr._sitepong_startTime ? Date.now() - xhr._sitepong_startTime : void 0;
4311
+ if (addBreadcrumbs) {
4312
+ addBreadcrumb({
4313
+ type: "http",
4314
+ category: "xhr",
4315
+ message: `${method} ${url}`,
4316
+ level: xhr.status >= 400 ? "error" : "info",
4317
+ data: {
4318
+ method,
4319
+ url: url.length > maxDataSize ? url.substring(0, maxDataSize) : url,
4320
+ status_code: xhr.status,
4321
+ duration_ms: duration
4322
+ }
4323
+ });
4324
+ }
4325
+ };
4326
+ this.addEventListener("loadend", onLoadEnd);
4327
+ return originalSend.call(this, body);
4328
+ };
4329
+ return () => {
4330
+ XMLHttpRequest.prototype.open = originalOpen;
4331
+ XMLHttpRequest.prototype.send = originalSend;
4332
+ };
4333
+ }
4334
+ var RNAutocaptureModule = class {
4335
+ constructor(options = {}) {
4336
+ this.appStateSubscription = null;
4337
+ this.currentAppState = "active";
4338
+ this.options = {
4339
+ trackAppState: options.trackAppState ?? true
4340
+ };
4249
4341
  }
4250
- /** Flush all remaining pending taps (session end). `_t` is the session-end
4251
- * clock, accepted for API symmetry with advance(); evaluation uses each tap's
4252
- * own outcome window. */
4253
- finalize(_t) {
4254
- const ready = this.pending;
4255
- this.pending = [];
4256
- for (const p of ready) this.evaluateTap(p);
4257
- this.stream.sort((a, b) => a.t - b.t);
4342
+ start() {
4343
+ if (this.options.trackAppState) {
4344
+ this.setupAppStateTracking();
4345
+ }
4258
4346
  }
4259
- getStream() {
4260
- return [...this.stream].sort((a, b) => a.t - b.t);
4347
+ stop() {
4348
+ if (this.appStateSubscription) {
4349
+ this.appStateSubscription.remove();
4350
+ this.appStateSubscription = null;
4351
+ }
4261
4352
  }
4262
- get viewportSize() {
4263
- return this.viewport;
4353
+ setupAppStateTracking() {
4354
+ try {
4355
+ this.currentAppState = reactNative.AppState.currentState;
4356
+ this.appStateSubscription = reactNative.AppState.addEventListener("change", (nextAppState) => {
4357
+ const previousState = this.currentAppState;
4358
+ this.currentAppState = nextAppState;
4359
+ if (previousState !== nextAppState) {
4360
+ track("$app_state_change", {
4361
+ from: previousState,
4362
+ to: nextAppState
4363
+ });
4364
+ if (previousState.match(/inactive|background/) && nextAppState === "active") {
4365
+ track("$app_foreground", {
4366
+ previous_state: previousState
4367
+ });
4368
+ } else if (previousState === "active" && nextAppState.match(/inactive|background/)) {
4369
+ track("$app_background", {
4370
+ next_state: nextAppState
4371
+ });
4372
+ }
4373
+ }
4374
+ });
4375
+ } catch {
4376
+ }
4264
4377
  }
4265
4378
  };
4266
4379
 
4267
- // src/react-native/structural/install-hook.ts
4268
- var g = globalThis;
4269
- var hookState = g.__SITEPONG_STRUCTURAL_HOOK_STATE__ ?? (g.__SITEPONG_STRUCTURAL_HOOK_STATE__ = {
4270
- latestRoot: null,
4271
- dirty: false
4272
- });
4273
- function record(root) {
4274
- hookState.latestRoot = root;
4275
- hookState.dirty = true;
4380
+ // src/react-native/performance.ts
4381
+ var coldStartTimestamp = null;
4382
+ var coldStartTracked = false;
4383
+ function markColdStart() {
4384
+ coldStartTimestamp = Date.now();
4276
4385
  }
4277
- function installStructuralHook() {
4278
- if (g.__SITEPONG_STRUCTURAL_HOOK_INSTALLED__) return;
4279
- const existing = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
4280
- if (existing) {
4281
- const prevRaw = existing.onCommitFiberRoot;
4282
- const prev = typeof prevRaw === "function" ? prevRaw.bind(existing) : null;
4283
- existing.onCommitFiberRoot = (id, root, ...rest) => {
4284
- record(root);
4285
- return prev ? prev(id, root, ...rest) : void 0;
4286
- };
4287
- } else {
4288
- const renderers = /* @__PURE__ */ new Map();
4289
- g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
4290
- renderers,
4291
- supportsFiber: true,
4292
- inject(renderer) {
4293
- const id = renderers.size + 1;
4294
- renderers.set(id, renderer);
4295
- return id;
4296
- },
4297
- onCommitFiberRoot(_id, root) {
4298
- record(root);
4299
- },
4300
- onCommitFiberUnmount() {
4301
- },
4302
- onScheduleFiberRoot() {
4303
- },
4304
- on() {
4305
- },
4306
- off() {
4307
- },
4308
- sub() {
4309
- return () => {
4310
- };
4311
- },
4312
- checkDCE() {
4313
- }
4314
- };
4386
+ var RNPerformanceManager = class {
4387
+ constructor() {
4388
+ this.screenRenderStarts = /* @__PURE__ */ new Map();
4315
4389
  }
4316
- g.__SITEPONG_STRUCTURAL_HOOK_INSTALLED__ = true;
4317
- }
4318
- var HostComponent = 5;
4319
- var HostText = 6;
4320
- function mapTag(type) {
4321
- if (typeof type !== "string") return "View";
4322
- const t = type;
4323
- if (/TextInput|TextField/i.test(t)) return "TextInput";
4324
- if (/Image/i.test(t)) return "Image";
4325
- if (/ScrollView/i.test(t)) return "ScrollView";
4326
- if (/Modal/i.test(t)) return "Modal";
4327
- if (/Text|RawText/i.test(t)) return "Text";
4328
- if (/Pressable|Button|Touchable/i.test(t)) return "Pressable";
4329
- return "View";
4330
- }
4331
- function nativeTagOf(stateNode) {
4332
- if (!stateNode) return void 0;
4333
- return stateNode.__nativeTag ?? stateNode._nativeTag ?? stateNode.canonical?.nativeTag ?? stateNode.canonical?._nativeTag ?? (typeof stateNode === "number" ? stateNode : void 0);
4334
- }
4335
- var STYLE_KEYS = /* @__PURE__ */ new Set([
4336
- "flex",
4337
- "flexDirection",
4338
- "flexGrow",
4339
- "flexShrink",
4340
- "flexWrap",
4341
- "alignItems",
4342
- "alignSelf",
4343
- "justifyContent",
4344
- "width",
4345
- "height",
4346
- "minWidth",
4347
- "minHeight",
4348
- "maxWidth",
4349
- "maxHeight",
4350
- "margin",
4351
- "marginTop",
4352
- "marginBottom",
4353
- "marginLeft",
4354
- "marginRight",
4355
- "marginHorizontal",
4356
- "marginVertical",
4357
- "padding",
4358
- "paddingTop",
4359
- "paddingBottom",
4360
- "paddingLeft",
4361
- "paddingRight",
4362
- "paddingHorizontal",
4363
- "paddingVertical",
4364
- "backgroundColor",
4365
- "borderRadius",
4366
- "borderWidth",
4367
- "borderColor",
4368
- "opacity",
4369
- "position",
4370
- "top",
4371
- "left",
4372
- "right",
4373
- "bottom",
4374
- "color",
4375
- "fontSize",
4376
- "fontWeight",
4377
- "textAlign",
4378
- "lineHeight",
4379
- "gap",
4380
- "overflow",
4381
- "shadowColor",
4382
- "shadowOpacity",
4383
- "shadowRadius",
4384
- "elevation",
4385
- "transform",
4386
- "zIndex",
4387
- "aspectRatio"
4388
- ]);
4389
- function extractStyle(style) {
4390
+ /**
4391
+ * Track cold start time from markColdStart() to now.
4392
+ * Should be called once the app is interactive (e.g., in the root component's useEffect).
4393
+ */
4394
+ trackColdStart() {
4395
+ if (coldStartTracked || !coldStartTimestamp) return;
4396
+ const duration = Date.now() - coldStartTimestamp;
4397
+ coldStartTracked = true;
4398
+ track("$cold_start", {
4399
+ duration_ms: duration
4400
+ });
4401
+ }
4402
+ /**
4403
+ * Start tracking a screen render. Call before rendering starts.
4404
+ */
4405
+ startScreenRender(screenName) {
4406
+ this.screenRenderStarts.set(screenName, Date.now());
4407
+ }
4408
+ /**
4409
+ * End tracking a screen render. Call after rendering completes.
4410
+ */
4411
+ endScreenRender(screenName) {
4412
+ const startTime = this.screenRenderStarts.get(screenName);
4413
+ if (startTime === void 0) return null;
4414
+ this.screenRenderStarts.delete(screenName);
4415
+ const duration = Date.now() - startTime;
4416
+ track("$screen_render", {
4417
+ screen_name: screenName,
4418
+ duration_ms: duration
4419
+ });
4420
+ return duration;
4421
+ }
4422
+ /**
4423
+ * Track a screen render using a useEffect-friendly pattern.
4424
+ * Returns a cleanup function.
4425
+ */
4426
+ trackScreenRender(screenName) {
4427
+ this.startScreenRender(screenName);
4428
+ return () => {
4429
+ this.endScreenRender(screenName);
4430
+ };
4431
+ }
4432
+ };
4433
+
4434
+ // src/react-native/push.ts
4435
+ var cachedDeviceContext = null;
4436
+ function getEndpoint() {
4437
+ const cfg = sitepong.config;
4438
+ return cfg?.endpoint || "https://ingest.sitepong.com";
4439
+ }
4440
+ function getApiKey() {
4441
+ const cfg = sitepong.config;
4442
+ return cfg?.apiKey || "";
4443
+ }
4444
+ function getDeviceContext() {
4445
+ if (cachedDeviceContext) return cachedDeviceContext;
4390
4446
  try {
4391
- const flat = reactNative.StyleSheet.flatten(style);
4392
- if (!flat) return void 0;
4393
- const out = {};
4394
- for (const k of Object.keys(flat)) if (STYLE_KEYS.has(k)) out[k] = flat[k];
4395
- return Object.keys(out).length ? JSON.stringify(out) : void 0;
4447
+ const info = collectDeviceInfo();
4448
+ cachedDeviceContext = {
4449
+ platform: info.platform,
4450
+ appVersion: info.appVersion,
4451
+ deviceModel: info.deviceModel,
4452
+ osVersion: info.osVersion
4453
+ };
4454
+ fetchPersistentDeviceId().then((id) => {
4455
+ if (id && cachedDeviceContext) cachedDeviceContext.deviceId = id;
4456
+ });
4396
4457
  } catch {
4397
- return void 0;
4458
+ cachedDeviceContext = {};
4398
4459
  }
4460
+ return cachedDeviceContext;
4399
4461
  }
4400
- function extractAttrs(tag, props, componentName2) {
4401
- const attrs = {};
4402
- if (!props) return attrs;
4403
- if (props.testID) attrs.testID = String(props.testID);
4404
- if (props.nativeID) attrs.nativeID = String(props.nativeID);
4405
- if (props.accessibilityLabel) attrs.accessibilityLabel = String(props.accessibilityLabel);
4406
- if (props.accessibilityRole) attrs.role = String(props.accessibilityRole);
4407
- if (props["aria-label"]) attrs.accessibilityLabel = String(props["aria-label"]);
4408
- const style = extractStyle(props.style);
4409
- if (style) attrs.style = style;
4410
- if (tag === "Image") {
4411
- const uri = props.source?.uri ?? (typeof props.source === "string" ? props.source : void 0);
4412
- if (uri) attrs.src = String(uri);
4462
+ async function postToIngest(path, body) {
4463
+ const endpoint = getEndpoint();
4464
+ const apiKey = getApiKey();
4465
+ if (!apiKey) {
4466
+ console.warn("[SitePong] Cannot register push token: SDK not initialized");
4467
+ return;
4468
+ }
4469
+ try {
4470
+ const response = await fetch(`${endpoint}${path}`, {
4471
+ method: "POST",
4472
+ headers: {
4473
+ "Content-Type": "application/json",
4474
+ "X-API-Key": apiKey
4475
+ },
4476
+ body: JSON.stringify(body)
4477
+ });
4478
+ if (!response.ok) {
4479
+ console.warn(`[SitePong] Push token registration failed: HTTP ${response.status}`);
4480
+ }
4481
+ } catch (err) {
4482
+ console.warn("[SitePong] Push token registration failed:", err);
4413
4483
  }
4414
- if (componentName2) attrs.rnComponent = componentName2;
4415
- if (props.wtOverlay || props.accessibilityViewIsModal) attrs.wtOverlay = "true";
4416
- if (props.wtSensitive || props.secureTextEntry) attrs.wtSensitive = "true";
4417
- return attrs;
4418
4484
  }
4419
- function collectText(fiber) {
4420
- let out = "";
4421
- const visit = (f) => {
4422
- if (!f) return;
4423
- if (f.tag === HostText) {
4424
- const p = f.memoizedProps;
4425
- if (typeof p === "string") out += p;
4426
- else if (p && typeof p.text === "string") out += p.text;
4427
- } else if (f.tag === HostComponent && typeof f.type === "string" && /RawText|VirtualText/i.test(f.type)) {
4428
- const p = f.memoizedProps;
4429
- if (p && typeof p.text === "string") out += p.text;
4485
+ async function deleteFromIngest(path, body) {
4486
+ const endpoint = getEndpoint();
4487
+ const apiKey = getApiKey();
4488
+ if (!apiKey) return;
4489
+ try {
4490
+ const response = await fetch(`${endpoint}${path}`, {
4491
+ method: "DELETE",
4492
+ headers: {
4493
+ "Content-Type": "application/json",
4494
+ "X-API-Key": apiKey
4495
+ },
4496
+ body: JSON.stringify(body)
4497
+ });
4498
+ if (!response.ok) {
4499
+ console.warn(`[SitePong] Push token deactivation failed: HTTP ${response.status}`);
4430
4500
  }
4431
- visit(f.child);
4432
- visit(f.sibling);
4501
+ } catch (err) {
4502
+ console.warn("[SitePong] Push token deactivation failed:", err);
4503
+ }
4504
+ }
4505
+ function registerPushToken(token, options) {
4506
+ const device = getDeviceContext();
4507
+ postToIngest("/api/push/tokens", {
4508
+ expo_push_token: token,
4509
+ environment: options.environment,
4510
+ device_id: device.deviceId,
4511
+ platform: device.platform,
4512
+ app_version: device.appVersion,
4513
+ device_model: device.deviceModel,
4514
+ os_version: device.osVersion
4515
+ });
4516
+ }
4517
+ function registerDeviceToken(token, options) {
4518
+ const device = getDeviceContext();
4519
+ const tokenType = options.platform === "ios" ? "apns" : "fcm";
4520
+ postToIngest("/api/push/tokens", {
4521
+ native_device_token: token,
4522
+ token_type: tokenType,
4523
+ environment: options.environment,
4524
+ device_id: device.deviceId,
4525
+ platform: options.platform,
4526
+ app_version: device.appVersion,
4527
+ device_model: device.deviceModel,
4528
+ os_version: device.osVersion
4529
+ });
4530
+ }
4531
+ function registerLiveActivityToken(activityType, activityId, pushToken, options) {
4532
+ const device = getDeviceContext();
4533
+ postToIngest("/api/push/live-activity-tokens", {
4534
+ activity_type: activityType,
4535
+ activity_id: activityId,
4536
+ push_token: pushToken,
4537
+ environment: options.environment,
4538
+ device_id: device.deviceId
4539
+ });
4540
+ }
4541
+ function registerPushToStartToken(activityType, pushToStartToken, options) {
4542
+ const device = getDeviceContext();
4543
+ postToIngest("/api/push/push-to-start-tokens", {
4544
+ activity_type: activityType,
4545
+ push_to_start_token: pushToStartToken,
4546
+ environment: options.environment,
4547
+ device_id: device.deviceId
4548
+ });
4549
+ }
4550
+ function endLiveActivity(activityType, activityId) {
4551
+ deleteFromIngest("/api/push/live-activity-tokens", {
4552
+ activity_type: activityType,
4553
+ activity_id: activityId
4554
+ });
4555
+ }
4556
+ var SitePongRNContext = React2.createContext({
4557
+ isInitialized: false,
4558
+ performanceManager: null
4559
+ });
4560
+ function SitePongRNProvider({
4561
+ apiKey,
4562
+ config = {},
4563
+ asyncStorage,
4564
+ navigationRef,
4565
+ trackNetwork = true,
4566
+ trackAppState = true,
4567
+ captureErrors = true,
4568
+ screenRecording,
4569
+ devicePushToken,
4570
+ devicePlatform,
4571
+ pushEnvironment = "production",
4572
+ pushUserId,
4573
+ children
4574
+ }) {
4575
+ const initialized = React2.useRef(false);
4576
+ const [sdkReady, setSdkReady] = React2.useState(false);
4577
+ const performanceManagerRef = React2.useRef(null);
4578
+ React2.useEffect(() => {
4579
+ if (initialized.current) return;
4580
+ const teardowns = [];
4581
+ const deviceInfo = collectDeviceInfo();
4582
+ setRNDeviceInfo({
4583
+ platform: "react-native",
4584
+ osName: deviceInfo.platform,
4585
+ osVersion: deviceInfo.osVersion,
4586
+ deviceName: deviceInfo.deviceName,
4587
+ deviceBrand: deviceInfo.deviceBrand,
4588
+ deviceModel: deviceInfo.deviceModel,
4589
+ appVersion: deviceInfo.appVersion,
4590
+ screenWidth: deviceInfo.screenWidth,
4591
+ screenHeight: deviceInfo.screenHeight,
4592
+ isEmulator: deviceInfo.isEmulator
4593
+ });
4594
+ fetchPersistentDeviceId().then((id) => {
4595
+ if (id) setNativeDeviceId(id);
4596
+ }).catch(() => {
4597
+ });
4598
+ const storageAdapter = asyncStorage ? createAsyncStorageAdapter(asyncStorage) : void 0;
4599
+ initRN({
4600
+ apiKey,
4601
+ ...config,
4602
+ _storageAdapter: storageAdapter
4603
+ });
4604
+ if (captureErrors) {
4605
+ const teardownErrors = setupRNErrorHandler();
4606
+ teardowns.push(teardownErrors);
4607
+ }
4608
+ if (trackNetwork) {
4609
+ const teardownNetwork = setupNetworkInterception();
4610
+ teardowns.push(teardownNetwork);
4611
+ }
4612
+ if (trackAppState) {
4613
+ const autocapture = new RNAutocaptureModule({ trackAppState: true });
4614
+ autocapture.start();
4615
+ teardowns.push(() => autocapture.stop());
4616
+ }
4617
+ if (navigationRef) {
4618
+ const teardownNav = createNavigationTracker(navigationRef);
4619
+ teardowns.push(teardownNav);
4620
+ }
4621
+ if (screenRecording?.enabled) {
4622
+ const sessionId = `rn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
4623
+ const endpoint = config.endpoint || "https://ingest.sitepong.com";
4624
+ startScreenRecordingOnError(apiKey, endpoint, sessionId, screenRecording);
4625
+ teardowns.push(() => stopScreenRecording());
4626
+ }
4627
+ if (devicePushToken && devicePlatform) {
4628
+ registerDeviceToken(devicePushToken, { platform: devicePlatform, environment: pushEnvironment});
4629
+ }
4630
+ performanceManagerRef.current = new RNPerformanceManager();
4631
+ performanceManagerRef.current.trackColdStart();
4632
+ initialized.current = true;
4633
+ setSdkReady(true);
4634
+ return () => {
4635
+ for (const teardown of teardowns) {
4636
+ teardown();
4637
+ }
4638
+ };
4639
+ }, [apiKey]);
4640
+ const value = {
4641
+ isInitialized: sdkReady,
4642
+ performanceManager: performanceManagerRef.current
4433
4643
  };
4434
- visit(fiber.child);
4435
- return out;
4644
+ return /* @__PURE__ */ jsxRuntime.jsx(SitePongRNContext.Provider, { value, children });
4436
4645
  }
4437
- function buildHostChildren(fiber, inheritedName, index, path) {
4438
- const out = [];
4439
- let f = fiber;
4440
- while (f) {
4441
- collectFrom(f, inheritedName, out, index, path);
4442
- f = f.sibling;
4443
- }
4444
- return out;
4646
+ function useSitePongRNContext() {
4647
+ return React2.useContext(SitePongRNContext);
4445
4648
  }
4446
- function componentName(fiber) {
4447
- const t = fiber?.type;
4448
- if (typeof t === "function") return t.displayName || t.name || void 0;
4449
- if (t && typeof t === "object") return t.displayName || void 0;
4450
- return void 0;
4649
+ function useScreenTrack(screenName) {
4650
+ const { performanceManager } = React2.useContext(SitePongRNContext);
4651
+ React2.useEffect(() => {
4652
+ if (!performanceManager) return;
4653
+ performanceManager.startScreenRender(screenName);
4654
+ return () => {
4655
+ performanceManager.endScreenRender(screenName);
4656
+ };
4657
+ }, [screenName, performanceManager]);
4451
4658
  }
4452
- function collectFrom(fiber, inheritedName, out, index, parentPath) {
4453
- if (!fiber) return;
4454
- if (fiber.tag === HostComponent) {
4455
- const tag = mapTag(fiber.type);
4456
- const attrs = extractAttrs(tag, fiber.memoizedProps, inheritedName);
4457
- const stateNode = fiber.stateNode;
4458
- const key = stateNode && typeof stateNode === "object" ? stateNode : fiber;
4459
- const isTextLeaf = tag === "Text";
4460
- const text = isTextLeaf ? collectText(fiber) : void 0;
4461
- const nthOfType = out.filter((n) => n.tag === tag).length + 1;
4462
- const myPath = [...parentPath, { tag, attrs, nthOfType }];
4463
- const children = isTextLeaf ? [] : buildHostChildren(fiber.child, void 0, index, myPath);
4464
- const vnode = { key, tag, attrs, children, ...text ? { text } : {} };
4465
- out.push(vnode);
4466
- const nt = nativeTagOf(stateNode);
4467
- if (nt !== void 0) index.set(nt, { node: vnode, path: myPath });
4468
- return;
4469
- }
4470
- const name = componentName(fiber) ?? inheritedName;
4471
- let child = fiber.child;
4472
- while (child) {
4473
- collectFrom(child, name, out, index, parentPath);
4474
- child = child.sibling;
4475
- }
4659
+ function useAppState() {
4660
+ const [appState, setAppState] = React2.useState("active");
4661
+ React2.useEffect(() => {
4662
+ setAppState(reactNative.AppState.currentState);
4663
+ const subscription = reactNative.AppState.addEventListener("change", (nextState) => {
4664
+ setAppState(nextState);
4665
+ });
4666
+ return () => subscription.remove();
4667
+ }, []);
4668
+ return appState;
4476
4669
  }
4477
- function walkFiberRoot(root) {
4478
- const index = /* @__PURE__ */ new Map();
4479
- const current = root?.current;
4480
- if (!current) return { tree: null, index };
4481
- const rootKey = root ?? current;
4482
- const children = buildHostChildren(current.child, void 0, index, []);
4483
- const tree = { key: rootKey, tag: "View", attrs: { style: JSON.stringify({ flex: 1 }) }, children };
4484
- return { tree, index };
4670
+ function useRemoteConfig() {
4671
+ const [config, setConfig] = React2.useState(() => getRemoteConfig());
4672
+ React2.useEffect(() => {
4673
+ setConfig(getRemoteConfig());
4674
+ const unsubscribe = onRemoteConfigChange((newConfig) => {
4675
+ setConfig(newConfig);
4676
+ });
4677
+ return unsubscribe;
4678
+ }, []);
4679
+ return config;
4485
4680
  }
4486
-
4487
- // src/react-native/structural/capture.ts
4488
- function genSessionId() {
4489
- const rand = Math.floor(Math.random() * 16777215).toString(36);
4490
- return `wt-${Date.now().toString(36)}-${rand}`;
4681
+ function useRNPerformance(screenName) {
4682
+ const { performanceManager } = React2.useContext(SitePongRNContext);
4683
+ const [duration, setDuration] = React2.useState(null);
4684
+ const startTimeRef = React2.useRef(Date.now());
4685
+ React2.useEffect(() => {
4686
+ startTimeRef.current = Date.now();
4687
+ const handle = requestAnimationFrame(() => {
4688
+ const d = Date.now() - startTimeRef.current;
4689
+ setDuration(d);
4690
+ if (performanceManager) {
4691
+ performanceManager.startScreenRender(screenName);
4692
+ performanceManager.endScreenRender(screenName);
4693
+ }
4694
+ });
4695
+ return () => cancelAnimationFrame(handle);
4696
+ }, [screenName, performanceManager]);
4697
+ return { duration };
4491
4698
  }
4492
- var StructuralCapture = class {
4493
- constructor() {
4494
- this.recorder = new Recorder();
4495
- this.currentIndex = /* @__PURE__ */ new Map();
4496
- this.started = 0;
4497
- this.sampleTimer = null;
4498
- this.flushTimer = null;
4499
- this.screen = "";
4500
- this.prevScreen = "";
4501
- this.cfg = null;
4502
- this.sessionId = "";
4503
- this.running = false;
4504
- }
4505
- now() {
4506
- return Date.now() - this.started;
4507
- }
4508
- isRunning() {
4509
- return this.running;
4510
- }
4511
- getSessionId() {
4512
- return this.sessionId;
4513
- }
4514
- start(cfg) {
4515
- if (this.running) return;
4516
- installStructuralHook();
4517
- this.cfg = cfg;
4518
- this.sessionId = cfg.sessionId || genSessionId();
4519
- this.started = Date.now();
4520
- this.running = true;
4521
- const { width, height } = reactNative.Dimensions.get("window");
4522
- this.recorder.setViewport(Math.round(width), Math.round(height), 0);
4523
- this.sampleTimer = setInterval(() => this.sample(), cfg.sampleIntervalMs ?? 120);
4524
- this.flushTimer = setInterval(() => void this.flush(), cfg.flushIntervalMs ?? 4e3);
4525
- if (cfg.debug) console.log("[structural] started", this.sessionId);
4526
- setTimeout(() => this.sample(true), 0);
4527
- }
4528
- sample(force = false) {
4529
- if (!this.running) return;
4530
- if (!force && !hookState.dirty) return;
4531
- hookState.dirty = false;
4532
- const root = hookState.latestRoot;
4533
- if (!root) return;
4534
- try {
4535
- const { tree, index } = walkFiberRoot(root);
4536
- this.currentIndex = index;
4537
- this.recorder.commit(tree, this.now());
4538
- } catch (e) {
4539
- if (this.cfg?.debug) console.log("[structural] sample error", String(e));
4699
+ var warnedMissing = false;
4700
+ function loadBridge() {
4701
+ return getScreenRecorderModule();
4702
+ }
4703
+ var started = false;
4704
+ function startWatchtower(config) {
4705
+ if (started) return;
4706
+ const bridge = loadBridge();
4707
+ if (!bridge) {
4708
+ if (!warnedMissing) {
4709
+ warnedMissing = true;
4710
+ console.warn(
4711
+ "[SitePong] Watchtower is enabled but the native module is unavailable. Rebuild the native app (npx expo prebuild && npx expo run:ios) so the bundled SitePong native module is linked."
4712
+ );
4540
4713
  }
4714
+ return;
4541
4715
  }
4542
- setScreen(name) {
4543
- if (!this.running || name === this.screen) return;
4544
- this.prevScreen = this.screen;
4545
- this.screen = name;
4546
- this.sample(true);
4547
- this.recorder.screenView(name, this.prevScreen, this.now());
4716
+ bridge.watchtowerStart({
4717
+ apiKey: config.apiKey,
4718
+ projectId: config.projectId,
4719
+ endpoint: config.endpoint,
4720
+ sampleRate: config.sampleRate ?? 0.1,
4721
+ platform: config.platform ?? "react-native-ios"
4722
+ });
4723
+ started = true;
4724
+ if (config.distinctId) setWatchtowerUser(config.distinctId);
4725
+ }
4726
+ function setWatchtowerUser(distinctId) {
4727
+ try {
4728
+ loadBridge()?.watchtowerSetUser(distinctId);
4729
+ } catch {
4548
4730
  }
4549
- /** Resolve a native touch to a node + emit a tap. `target` is the RN
4550
- * nativeTag from `nativeEvent.target`. */
4551
- handleTap(target, pageX, pageY) {
4552
- if (!this.running) return;
4553
- const entry = this.currentIndex.get(target);
4554
- if (!entry) return;
4555
- const { width, height } = reactNative.Dimensions.get("window");
4556
- this.recorder.tap(
4557
- {
4558
- key: entry.node.key,
4559
- path: entry.path,
4560
- attrs: entry.node.attrs,
4561
- text: entry.node.text,
4562
- normX: pageX / (width || 1),
4563
- normY: pageY / (height || 1)
4564
- },
4565
- this.now()
4566
- );
4731
+ }
4732
+ function getWatchtowerSessionId() {
4733
+ try {
4734
+ return loadBridge()?.watchtowerGetSessionId() ?? null;
4735
+ } catch {
4736
+ return null;
4567
4737
  }
4568
- async flush() {
4569
- if (!this.running || !this.cfg) return;
4570
- this.recorder.advance(this.now());
4571
- const messages = this.recorder.getStream();
4572
- if (messages.length === 0) return;
4573
- const vp = this.recorder.viewportSize;
4574
- try {
4575
- const headers = {
4576
- "content-type": "application/json",
4577
- "X-API-Key": this.cfg.apiKey
4578
- };
4579
- if (this.cfg.projectId) headers["X-Project-ID"] = this.cfg.projectId;
4580
- await fetch(`${this.cfg.endpoint.replace(/\/$/, "")}/api/replay-stream`, {
4581
- method: "POST",
4582
- headers,
4583
- body: JSON.stringify({
4584
- session_id: this.sessionId,
4585
- platform: this.cfg.platform,
4586
- viewport: { w: vp.w, h: vp.h },
4587
- messages
4588
- })
4589
- });
4590
- if (this.cfg.debug) console.log("[structural] flushed", messages.length, "messages");
4591
- } catch (e) {
4592
- if (this.cfg.debug) console.log("[structural] flush failed", String(e));
4738
+ }
4739
+ function stopWatchtower() {
4740
+ if (!started) return;
4741
+ loadBridge()?.watchtowerStop();
4742
+ started = false;
4743
+ }
4744
+ function subscribeNavigationToWatchtower(navigationRef) {
4745
+ const unsubscribe = createNavigationTracker(navigationRef, {
4746
+ screenNameFilter: (name) => {
4747
+ try {
4748
+ loadBridge()?.watchtowerSetScreen(name);
4749
+ } catch {
4750
+ }
4751
+ return name;
4593
4752
  }
4753
+ });
4754
+ try {
4755
+ const route = navigationRef.getCurrentRoute?.();
4756
+ if (route?.name) loadBridge()?.watchtowerSetScreen(route.name);
4757
+ } catch {
4594
4758
  }
4595
- async stop() {
4596
- if (!this.running) return;
4597
- this.recorder.finalize(this.now());
4598
- if (this.sampleTimer) clearInterval(this.sampleTimer);
4599
- if (this.flushTimer) clearInterval(this.flushTimer);
4600
- this.sampleTimer = null;
4601
- this.flushTimer = null;
4602
- this.running = false;
4603
- await this.flush();
4604
- }
4605
- };
4606
- var structuralCapture = new StructuralCapture();
4759
+ return unsubscribe;
4760
+ }
4761
+ function WatchtowerProvider(props) {
4762
+ const { config, navigationRef, children } = props;
4763
+ React2__default.default.useEffect(() => {
4764
+ startWatchtower(config);
4765
+ const unsubscribe = subscribeNavigationToWatchtower(navigationRef);
4766
+ return () => {
4767
+ unsubscribe();
4768
+ stopWatchtower();
4769
+ };
4770
+ }, []);
4771
+ return React2__default.default.createElement(React2__default.default.Fragment, null, children);
4772
+ }
4607
4773
  var TAP_MOVE_THRESHOLD_PX = 12;
4608
4774
  function StructuralCaptureProvider({ children, ...cfg }) {
4775
+ const cfgRef = React2.useRef(cfg);
4776
+ cfgRef.current = cfg;
4777
+ const rootRef = React2.useRef(null);
4609
4778
  React2.useEffect(() => {
4610
- structuralCapture.start(cfg);
4779
+ structuralCapture.start(cfgRef.current);
4780
+ structuralCapture.setRootInstance(rootRef.current);
4611
4781
  return () => {
4612
4782
  void structuralCapture.stop();
4613
4783
  };
4614
4784
  }, []);
4615
- const startPos = React2.useRef(null);
4785
+ const gesture = React2.useRef(null);
4616
4786
  const onTouchStart = (e) => {
4617
- const { pageX, pageY } = e.nativeEvent;
4618
- startPos.current = { x: pageX, y: pageY };
4787
+ const ne = e.nativeEvent;
4788
+ gesture.current = {
4789
+ startX: ne.pageX,
4790
+ startY: ne.pageY,
4791
+ lastX: ne.pageX,
4792
+ lastY: ne.pageY,
4793
+ target: Number(ne.target),
4794
+ scrolling: false
4795
+ };
4796
+ };
4797
+ const onTouchMove = (e) => {
4798
+ const g2 = gesture.current;
4799
+ if (!g2) return;
4800
+ const ne = e.nativeEvent;
4801
+ const movedFar = Math.abs(ne.pageX - g2.startX) > TAP_MOVE_THRESHOLD_PX || Math.abs(ne.pageY - g2.startY) > TAP_MOVE_THRESHOLD_PX;
4802
+ if (movedFar) g2.scrolling = true;
4803
+ if (g2.scrolling) {
4804
+ const dx = g2.lastX - ne.pageX;
4805
+ const dy = g2.lastY - ne.pageY;
4806
+ structuralCapture.handleScroll(g2.target, dx, dy);
4807
+ }
4808
+ g2.lastX = ne.pageX;
4809
+ g2.lastY = ne.pageY;
4619
4810
  };
4620
4811
  const onTouchEnd = (e) => {
4812
+ const g2 = gesture.current;
4813
+ gesture.current = null;
4814
+ if (!g2) return;
4815
+ if (g2.scrolling) return;
4621
4816
  const ne = e.nativeEvent;
4622
- const s = startPos.current;
4623
- startPos.current = null;
4624
- if (!s) return;
4625
- if (Math.abs(ne.pageX - s.x) > TAP_MOVE_THRESHOLD_PX || Math.abs(ne.pageY - s.y) > TAP_MOVE_THRESHOLD_PX) {
4817
+ if (Math.abs(ne.pageX - g2.startX) > TAP_MOVE_THRESHOLD_PX || Math.abs(ne.pageY - g2.startY) > TAP_MOVE_THRESHOLD_PX) {
4626
4818
  return;
4627
4819
  }
4628
- structuralCapture.handleTap(Number(ne.target), ne.pageX, ne.pageY);
4820
+ structuralCapture.handleTap(g2.target, ne.pageX, ne.pageY);
4629
4821
  };
4630
- return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: { flex: 1 }, onTouchStart, onTouchEnd, children });
4822
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { ref: rootRef, style: { flex: 1 }, onTouchStart, onTouchMove, onTouchEnd, children });
4631
4823
  }
4632
4824
  function useStructuralScreen(name) {
4633
4825
  React2.useEffect(() => {