sitepong 0.2.8 → 0.2.9

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,600 +3315,6 @@ 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;
3470
- };
3471
- }
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 {
3525
- constructor() {
3526
- this.screenRenderStarts = /* @__PURE__ */ new Map();
3527
- }
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
- });
3539
- }
3540
- /**
3541
- * Start tracking a screen render. Call before rendering starts.
3542
- */
3543
- startScreenRender(screenName) {
3544
- this.screenRenderStarts.set(screenName, Date.now());
3545
- }
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;
3559
- }
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
- };
3569
- }
3570
- };
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;
3594
- });
3595
- } catch {
3596
- cachedDeviceContext = {};
3597
- }
3598
- return cachedDeviceContext;
3599
- }
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;
3606
- }
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);
3621
- }
3622
- }
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);
3641
- }
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);
3831
- }
3832
- });
3833
- return () => cancelAnimationFrame(handle);
3834
- }, [screenName, performanceManager]);
3835
- return { duration };
3836
- }
3837
- var warnedMissing = false;
3838
- function loadBridge() {
3839
- return getScreenRecorderModule();
3840
- }
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
- );
3851
- }
3852
- return;
3853
- }
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);
3863
- }
3864
- function setWatchtowerUser(distinctId) {
3865
- try {
3866
- loadBridge()?.watchtowerSetUser(distinctId);
3867
- } catch {
3868
- }
3869
- }
3870
- function getWatchtowerSessionId() {
3871
- try {
3872
- return loadBridge()?.watchtowerGetSessionId() ?? null;
3873
- } catch {
3874
- return null;
3875
- }
3876
- }
3877
- function stopWatchtower() {
3878
- if (!started) return;
3879
- loadBridge()?.watchtowerStop();
3880
- started = false;
3881
- }
3882
- function subscribeNavigationToWatchtower(navigationRef) {
3883
- const unsubscribe = createNavigationTracker(navigationRef, {
3884
- screenNameFilter: (name) => {
3885
- try {
3886
- loadBridge()?.watchtowerSetScreen(name);
3887
- } catch {
3888
- }
3889
- return name;
3890
- }
3891
- });
3892
- try {
3893
- const route = navigationRef.getCurrentRoute?.();
3894
- if (route?.name) loadBridge()?.watchtowerSetScreen(route.name);
3895
- } catch {
3896
- }
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
3318
  // ../watchtower-recorder/dist/index.mjs
3913
3319
  var CreateElementNode = (id, parentID, index, tag) => [8, id, parentID, index, tag];
3914
3320
  var CreateTextNode = (id, parentID, index) => [
@@ -3936,6 +3342,12 @@ var RemoveNodeAttribute = (id, name) => [
3936
3342
  name
3937
3343
  ];
3938
3344
  var SetNodeData = (id, data) => [14, id, data];
3345
+ var SetNodeScroll = (id, x, y) => [
3346
+ 16,
3347
+ id,
3348
+ x,
3349
+ y
3350
+ ];
3939
3351
  var MobileTap = (id, hesitationMs, label, selector, normX, normY) => [68, id, hesitationMs, label, selector, normX, normY];
3940
3352
  var ScreenView = (name, prevName) => [
3941
3353
  200,
@@ -3957,677 +3369,1419 @@ var Mirror = class {
3957
3369
  __publicField(this, "byId", /* @__PURE__ */ new Map());
3958
3370
  __publicField(this, "next", 1);
3959
3371
  }
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);
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);
3376
+ }
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];
3386
+ }
3387
+ getKey(id) {
3388
+ return this.byId.get(id);
3389
+ }
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;
3400
+ }
3401
+ };
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
3416
+ });
3417
+ order.push(id);
3418
+ });
3419
+ return { next, order };
3420
+ }
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]));
3425
+ }
3426
+ for (const k of Object.keys(prev)) {
3427
+ if (!(k in next)) out.push(RemoveNodeAttribute(id, k));
3428
+ }
3429
+ return out;
3430
+ }
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));
3439
+ }
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));
3448
+ }
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 };
3460
+ }
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;
3465
+ }
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})`);
3487
+ }
3488
+ }
3489
+ return parts.join(" > ");
3490
+ }
3491
+ function truncate(s, n = 40) {
3492
+ return s.length > n ? s.slice(0, n) : s;
3493
+ }
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 "";
3500
+ }
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;
3568
+ }
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;
3619
+ }
3620
+ }
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);
3964
3636
  }
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];
3637
+ screenView(name, prevName, t) {
3638
+ this.emit(t, ScreenView(name, prevName));
3639
+ this.analyzer.recordScreenView(t);
3640
+ this.drainPending(t);
3974
3641
  }
3975
- getKey(id) {
3976
- return this.byId.get(id);
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)));
3977
3648
  }
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;
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]);
3985
3652
  }
3986
- get size() {
3987
- return this.byId.size;
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));
3667
+ }
3668
+ /** Explicit clock advance (adapter ticker) to close outcome windows even when
3669
+ * no commit/tap arrives. */
3670
+ advance(t) {
3671
+ this.drainPending(t);
3672
+ }
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);
3681
+ }
3682
+ getStream() {
3683
+ return [...this.stream].sort((a, b) => a.t - b.t);
3684
+ }
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;
3689
+ }
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));
3697
+ }
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
+
3924
+ // src/react-native/structural/capture.ts
3925
+ var WALK_BUDGET_MS = 24;
3926
+ var MAX_OVERLOAD_SKIP = 8;
3927
+ function genSessionId() {
3928
+ const rand = Math.floor(Math.random() * 16777215).toString(36);
3929
+ return `wt-${Date.now().toString(36)}-${rand}`;
4088
3930
  }
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 {
3931
+ var StructuralCapture = class {
4102
3932
  constructor() {
4103
- __publicField(this, "activity", []);
4104
- __publicField(this, "screenViews", []);
4105
- __publicField(this, "taps", []);
3933
+ this.recorder = new Recorder();
3934
+ this.currentIndex = /* @__PURE__ */ new Map();
3935
+ this.started = 0;
3936
+ this.sampleTimer = null;
3937
+ this.flushTimer = null;
3938
+ this.screen = "";
3939
+ this.prevScreen = "";
3940
+ this.cfg = null;
3941
+ this.sessionId = "";
3942
+ this.running = false;
3943
+ // Incremental delivery: cursor into the recorder's emission stream that has
3944
+ // been CONFIRMED stored. Everything at index >= sentCount is unsent (the
3945
+ // offline buffer). Advances only on a 2xx.
3946
+ this.sentCount = 0;
3947
+ this.flushing = false;
3948
+ // Persistent per-ScrollView content offset (keyed by native tag), so scroll
3949
+ // continues from where it was rather than resetting each gesture.
3950
+ this.scrollOffsets = /* @__PURE__ */ new Map();
3951
+ // Adaptive sampling: skip this many upcoming ticks after a heavy walk.
3952
+ this.overloadSkip = 0;
3953
+ this.appStateSub = null;
3954
+ this.teardowns = [];
4106
3955
  }
4107
- recordActivity(a) {
4108
- this.activity.push(a);
3956
+ now() {
3957
+ return Date.now() - this.started;
4109
3958
  }
4110
- recordScreenView(t) {
4111
- this.screenViews.push(t);
3959
+ isRunning() {
3960
+ return this.running;
4112
3961
  }
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 };
3962
+ getSessionId() {
3963
+ return this.sessionId;
4119
3964
  }
4120
- /**
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.
4123
- */
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 } };
3965
+ start(cfg) {
3966
+ if (this.running) return;
3967
+ installStructuralHook();
3968
+ this.cfg = cfg;
3969
+ this.sessionId = cfg.sessionId || genSessionId();
3970
+ this.started = Date.now();
3971
+ this.running = true;
3972
+ this.sentCount = 0;
3973
+ this.scrollOffsets.clear();
3974
+ this.overloadSkip = 0;
3975
+ const { width, height } = reactNative.Dimensions.get("window");
3976
+ this.recorder.setViewport(Math.round(width), Math.round(height), 0);
3977
+ this.sampleTimer = setInterval(() => this.sample(), cfg.sampleIntervalMs ?? 120);
3978
+ this.flushTimer = setInterval(() => void this.flush(), cfg.flushIntervalMs ?? 4e3);
3979
+ this.installCrashFlush();
3980
+ this.installBackgroundFlush();
3981
+ if (cfg.debug) console.log("[structural] started", this.sessionId);
3982
+ setTimeout(() => this.sample(true), 0);
4149
3983
  }
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 });
3984
+ /** Best-effort flush the pre-crash tail before the app tears down. */
3985
+ installCrashFlush() {
3986
+ if (typeof ErrorUtils === "undefined" || typeof ErrorUtils.setGlobalHandler !== "function") return;
3987
+ const prev = ErrorUtils.getGlobalHandler?.();
3988
+ ErrorUtils.setGlobalHandler((error, isFatal) => {
3989
+ void this.flush();
3990
+ if (typeof prev === "function") prev(error, isFatal);
3991
+ });
3992
+ this.teardowns.push(() => {
3993
+ if (typeof prev === "function") ErrorUtils.setGlobalHandler(prev);
3994
+ });
3995
+ }
3996
+ /** Flush on background/inactive so timer suspension doesn't strand the tail. */
3997
+ installBackgroundFlush() {
3998
+ const onChange = (state) => {
3999
+ if (state === "background" || state === "inactive") void this.flush();
4000
+ };
4001
+ this.appStateSub = reactNative.AppState.addEventListener("change", onChange);
4002
+ this.teardowns.push(() => {
4003
+ this.appStateSub?.remove();
4004
+ this.appStateSub = null;
4005
+ });
4006
+ }
4007
+ sample(force = false) {
4008
+ if (!this.running) return;
4009
+ if (!force && this.overloadSkip > 0) {
4010
+ this.overloadSkip--;
4011
+ return;
4012
+ }
4013
+ if (!force && !hookState.dirty) return;
4014
+ hookState.dirty = false;
4015
+ const root = hookState.latestRoot;
4016
+ if (!root) return;
4017
+ const t0 = Date.now();
4018
+ try {
4019
+ const { tree, index } = walkFiberRoot(root);
4020
+ this.currentIndex = index;
4021
+ this.recorder.commit(tree, this.now());
4022
+ } catch (e) {
4023
+ if (this.cfg?.debug) console.log("[structural] sample error", String(e));
4024
+ }
4025
+ const cost = Date.now() - t0;
4026
+ if (cost > WALK_BUDGET_MS) {
4027
+ this.overloadSkip = Math.min(MAX_OVERLOAD_SKIP, Math.ceil(cost / WALK_BUDGET_MS));
4028
+ }
4165
4029
  }
4166
- emit(t, m) {
4167
- this.stream.push({ t, m });
4030
+ setScreen(name) {
4031
+ if (!this.running || name === this.screen) return;
4032
+ this.prevScreen = this.screen;
4033
+ this.screen = name;
4034
+ this.sample(true);
4035
+ this.recorder.screenView(name, this.prevScreen, this.now());
4168
4036
  }
4169
- setViewport(w, h, t) {
4170
- this.viewport = { w, h };
4171
- this.emit(t, [5, w, h]);
4037
+ /** Resolve a native touch to a node + emit a tap. `target` is the RN
4038
+ * nativeTag from `nativeEvent.target`. */
4039
+ handleTap(target, pageX, pageY) {
4040
+ if (!this.running) return;
4041
+ const entry = this.currentIndex.get(target);
4042
+ if (!entry) return;
4043
+ const { width, height } = reactNative.Dimensions.get("window");
4044
+ this.recorder.tap(
4045
+ {
4046
+ key: entry.node.key,
4047
+ path: entry.path,
4048
+ attrs: entry.node.attrs,
4049
+ text: entry.node.text,
4050
+ normX: pageX / (width || 1),
4051
+ normY: pageY / (height || 1)
4052
+ },
4053
+ this.now()
4054
+ );
4172
4055
  }
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]);
4056
+ /** Feed a scroll/drag delta from a touch onto the nearest scrollable ancestor
4057
+ * of the touched node. `target` is the RN nativeTag under the finger; `dx`/`dy`
4058
+ * are content-offset deltas (px, sign = content motion). Native-driven scroll
4059
+ * never reaches the fiber walk, so this is the only way the replay can ride the
4060
+ * scroll instead of freezing on a stale frame. */
4061
+ handleScroll(target, dx, dy) {
4062
+ if (!this.running) return;
4063
+ const leaf = this.currentIndex.get(target);
4064
+ const scrollTag = leaf?.scrollAncestorTag;
4065
+ if (scrollTag === void 0) return;
4066
+ const scrollable = this.currentIndex.get(scrollTag);
4067
+ if (!scrollable) return;
4068
+ const cur = this.scrollOffsets.get(scrollTag) ?? { x: 0, y: 0 };
4069
+ const next = { x: Math.max(0, cur.x + dx), y: Math.max(0, cur.y + dy) };
4070
+ if (Math.abs(next.x - cur.x) < 1 && Math.abs(next.y - cur.y) < 1) return;
4071
+ this.scrollOffsets.set(scrollTag, next);
4072
+ this.recorder.scroll(scrollable.node.key, next.x, next.y, this.now());
4073
+ }
4074
+ /** Public best-effort flush — used by the shared RN error handler to push the
4075
+ * pre-crash tail (the in-class ErrorUtils hook covers apps that don't call
4076
+ * setupRNErrorHandler; both are idempotent via the `flushing` guard). */
4077
+ async flushNow() {
4078
+ await this.flush();
4079
+ }
4080
+ async flush() {
4081
+ if (!this.cfg || this.flushing) return;
4082
+ this.recorder.advance(this.now());
4083
+ const from = this.sentCount;
4084
+ const batch = this.recorder.sliceStream(from);
4085
+ if (batch.length === 0) return;
4086
+ this.flushing = true;
4087
+ const vp = this.recorder.viewportSize;
4088
+ try {
4089
+ const headers = {
4090
+ "content-type": "application/json",
4091
+ "X-API-Key": this.cfg.apiKey
4092
+ };
4093
+ if (this.cfg.projectId) headers["X-Project-ID"] = this.cfg.projectId;
4094
+ const res = await fetch(`${this.cfg.endpoint.replace(/\/$/, "")}/api/replay-stream`, {
4095
+ method: "POST",
4096
+ headers,
4097
+ body: JSON.stringify({
4098
+ session_id: this.sessionId,
4099
+ platform: this.cfg.platform,
4100
+ viewport: { w: vp.w, h: vp.h },
4101
+ seq_start: from,
4102
+ messages: batch
4103
+ })
4104
+ });
4105
+ if (res.ok) {
4106
+ this.sentCount = from + batch.length;
4107
+ if (this.cfg.debug) console.log("[structural] flushed", batch.length, "msgs @", from);
4108
+ } else if (this.cfg.debug) {
4109
+ console.log("[structural] flush rejected", res.status, "\u2014 will retry");
4110
+ }
4111
+ } catch (e) {
4112
+ if (this.cfg.debug) console.log("[structural] flush failed", String(e), "\u2014 buffered");
4113
+ } finally {
4114
+ this.flushing = false;
4181
4115
  }
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;
4116
+ }
4117
+ async stop() {
4118
+ if (!this.running) return;
4119
+ this.recorder.finalize(this.now());
4120
+ if (this.sampleTimer) clearInterval(this.sampleTimer);
4121
+ if (this.flushTimer) clearInterval(this.flushTimer);
4122
+ this.sampleTimer = null;
4123
+ this.flushTimer = null;
4124
+ this.running = false;
4125
+ for (const t of this.teardowns.splice(0)) {
4126
+ try {
4127
+ t();
4128
+ } catch {
4207
4129
  }
4208
4130
  }
4209
- const activity = { t, create, remove, move, attr, text, overlayAdd, overlayRemove };
4210
- this.analyzer.recordActivity(activity);
4211
- this.index = index;
4212
- this.drainPending(t);
4131
+ await this.flush();
4213
4132
  }
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);
4133
+ };
4134
+ var structuralCapture = new StructuralCapture();
4135
+
4136
+ // src/react-native/error-handler.ts
4137
+ function setupRNErrorHandler(options = {}) {
4138
+ const {
4139
+ captureFatal = true,
4140
+ captureNonFatal = true,
4141
+ capturePromiseRejections = true
4142
+ } = options;
4143
+ const teardowns = [];
4144
+ if (typeof ErrorUtils !== "undefined") {
4145
+ const previousHandler = ErrorUtils.getGlobalHandler();
4146
+ ErrorUtils.setGlobalHandler((error, isFatal) => {
4147
+ if (isFatal && captureFatal || !isFatal && captureNonFatal) {
4148
+ captureError(error, {
4149
+ tags: {
4150
+ fatal: String(isFatal),
4151
+ handler: "react-native-global"
4152
+ }
4153
+ });
4154
+ flushScreenRecording().catch(() => {
4155
+ });
4156
+ if (structuralCapture.isRunning()) {
4157
+ void structuralCapture.flushNow().catch(() => {
4158
+ });
4159
+ }
4160
+ }
4161
+ if (previousHandler) {
4162
+ previousHandler(error, isFatal);
4163
+ }
4164
+ });
4165
+ teardowns.push(() => {
4166
+ if (previousHandler) {
4167
+ ErrorUtils.setGlobalHandler(previousHandler);
4168
+ }
4169
+ });
4224
4170
  }
4225
- screenView(name, prevName, t) {
4226
- this.emit(t, ScreenView(name, prevName));
4227
- this.analyzer.recordScreenView(t);
4228
- this.drainPending(t);
4171
+ if (capturePromiseRejections) {
4172
+ if (typeof global !== "undefined") {
4173
+ const rejectionHandler = (event) => {
4174
+ const err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
4175
+ captureError(err, {
4176
+ tags: { handler: "unhandled-promise-rejection" }
4177
+ });
4178
+ flushScreenRecording().catch(() => {
4179
+ });
4180
+ if (structuralCapture.isRunning()) {
4181
+ void structuralCapture.flushNow().catch(() => {
4182
+ });
4183
+ }
4184
+ };
4185
+ if (typeof globalThis.addEventListener === "function") {
4186
+ globalThis.addEventListener("unhandledrejection", rejectionHandler);
4187
+ teardowns.push(() => {
4188
+ globalThis.removeEventListener("unhandledrejection", rejectionHandler);
4189
+ });
4190
+ }
4191
+ }
4229
4192
  }
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);
4193
+ return () => {
4194
+ for (const teardown of teardowns) {
4195
+ teardown();
4196
+ }
4197
+ };
4198
+ }
4199
+
4200
+ // src/react-native/navigation.ts
4201
+ function createNavigationTracker(navigationRef, options = {}) {
4202
+ const {
4203
+ trackScreenViews = true,
4204
+ addBreadcrumbs = true,
4205
+ screenNameFilter
4206
+ } = options;
4207
+ let currentRouteName;
4208
+ const unsubscribe = navigationRef.addListener("state", () => {
4209
+ const route = navigationRef.getCurrentRoute();
4210
+ if (!route) return;
4211
+ const previousRouteName = currentRouteName;
4212
+ currentRouteName = route.name;
4213
+ if (previousRouteName === currentRouteName) return;
4214
+ const screenName = screenNameFilter ? screenNameFilter(currentRouteName) : currentRouteName;
4215
+ if (!screenName) return;
4216
+ setCurrentScreen(screenName);
4217
+ if (trackScreenViews) {
4218
+ track("$screen_view", {
4219
+ screen_name: screenName,
4220
+ previous_screen: previousRouteName,
4221
+ params: route.params
4222
+ });
4223
+ }
4224
+ if (addBreadcrumbs) {
4225
+ addBreadcrumb({
4226
+ type: "navigation",
4227
+ category: "screen",
4228
+ message: `Navigated to ${screenName}`,
4229
+ data: {
4230
+ from: previousRouteName,
4231
+ to: screenName
4232
+ }
4233
+ });
4234
+ }
4235
+ });
4236
+ return () => {
4237
+ unsubscribe();
4238
+ setCurrentScreen(void 0);
4239
+ };
4240
+ }
4241
+
4242
+ // src/react-native/network.ts
4243
+ function setupNetworkInterception(options = {}) {
4244
+ const {
4245
+ addBreadcrumbs = true,
4246
+ ignoreUrls = [/sitepong\.com/],
4247
+ maxDataSize = 1024
4248
+ } = options;
4249
+ if (typeof XMLHttpRequest === "undefined") {
4250
+ return () => {
4251
+ };
4236
4252
  }
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));
4253
+ const originalOpen = XMLHttpRequest.prototype.open;
4254
+ const originalSend = XMLHttpRequest.prototype.send;
4255
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
4256
+ const xhr = this;
4257
+ xhr._sitepong_method = method;
4258
+ xhr._sitepong_url = String(url);
4259
+ return originalOpen.apply(this, [method, url, ...rest]);
4260
+ };
4261
+ XMLHttpRequest.prototype.send = function(body) {
4262
+ const xhr = this;
4263
+ const url = xhr._sitepong_url || "";
4264
+ const method = xhr._sitepong_method || "GET";
4265
+ const shouldIgnore = ignoreUrls.some((pattern) => {
4266
+ if (typeof pattern === "string") return url.includes(pattern);
4267
+ return pattern.test(url);
4268
+ });
4269
+ if (shouldIgnore) {
4270
+ return originalSend.call(this, body);
4242
4271
  }
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);
4272
+ xhr._sitepong_startTime = Date.now();
4273
+ const onLoadEnd = () => {
4274
+ const duration = xhr._sitepong_startTime ? Date.now() - xhr._sitepong_startTime : void 0;
4275
+ if (addBreadcrumbs) {
4276
+ addBreadcrumb({
4277
+ type: "http",
4278
+ category: "xhr",
4279
+ message: `${method} ${url}`,
4280
+ level: xhr.status >= 400 ? "error" : "info",
4281
+ data: {
4282
+ method,
4283
+ url: url.length > maxDataSize ? url.substring(0, maxDataSize) : url,
4284
+ status_code: xhr.status,
4285
+ duration_ms: duration
4286
+ }
4287
+ });
4288
+ }
4289
+ };
4290
+ this.addEventListener("loadend", onLoadEnd);
4291
+ return originalSend.call(this, body);
4292
+ };
4293
+ return () => {
4294
+ XMLHttpRequest.prototype.open = originalOpen;
4295
+ XMLHttpRequest.prototype.send = originalSend;
4296
+ };
4297
+ }
4298
+ var RNAutocaptureModule = class {
4299
+ constructor(options = {}) {
4300
+ this.appStateSubscription = null;
4301
+ this.currentAppState = "active";
4302
+ this.options = {
4303
+ trackAppState: options.trackAppState ?? true
4304
+ };
4249
4305
  }
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);
4306
+ start() {
4307
+ if (this.options.trackAppState) {
4308
+ this.setupAppStateTracking();
4309
+ }
4258
4310
  }
4259
- getStream() {
4260
- return [...this.stream].sort((a, b) => a.t - b.t);
4311
+ stop() {
4312
+ if (this.appStateSubscription) {
4313
+ this.appStateSubscription.remove();
4314
+ this.appStateSubscription = null;
4315
+ }
4261
4316
  }
4262
- get viewportSize() {
4263
- return this.viewport;
4317
+ setupAppStateTracking() {
4318
+ try {
4319
+ this.currentAppState = reactNative.AppState.currentState;
4320
+ this.appStateSubscription = reactNative.AppState.addEventListener("change", (nextAppState) => {
4321
+ const previousState = this.currentAppState;
4322
+ this.currentAppState = nextAppState;
4323
+ if (previousState !== nextAppState) {
4324
+ track("$app_state_change", {
4325
+ from: previousState,
4326
+ to: nextAppState
4327
+ });
4328
+ if (previousState.match(/inactive|background/) && nextAppState === "active") {
4329
+ track("$app_foreground", {
4330
+ previous_state: previousState
4331
+ });
4332
+ } else if (previousState === "active" && nextAppState.match(/inactive|background/)) {
4333
+ track("$app_background", {
4334
+ next_state: nextAppState
4335
+ });
4336
+ }
4337
+ }
4338
+ });
4339
+ } catch {
4340
+ }
4264
4341
  }
4265
4342
  };
4266
4343
 
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;
4344
+ // src/react-native/performance.ts
4345
+ var coldStartTimestamp = null;
4346
+ var coldStartTracked = false;
4347
+ function markColdStart() {
4348
+ coldStartTimestamp = Date.now();
4276
4349
  }
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
- };
4350
+ var RNPerformanceManager = class {
4351
+ constructor() {
4352
+ this.screenRenderStarts = /* @__PURE__ */ new Map();
4315
4353
  }
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) {
4354
+ /**
4355
+ * Track cold start time from markColdStart() to now.
4356
+ * Should be called once the app is interactive (e.g., in the root component's useEffect).
4357
+ */
4358
+ trackColdStart() {
4359
+ if (coldStartTracked || !coldStartTimestamp) return;
4360
+ const duration = Date.now() - coldStartTimestamp;
4361
+ coldStartTracked = true;
4362
+ track("$cold_start", {
4363
+ duration_ms: duration
4364
+ });
4365
+ }
4366
+ /**
4367
+ * Start tracking a screen render. Call before rendering starts.
4368
+ */
4369
+ startScreenRender(screenName) {
4370
+ this.screenRenderStarts.set(screenName, Date.now());
4371
+ }
4372
+ /**
4373
+ * End tracking a screen render. Call after rendering completes.
4374
+ */
4375
+ endScreenRender(screenName) {
4376
+ const startTime = this.screenRenderStarts.get(screenName);
4377
+ if (startTime === void 0) return null;
4378
+ this.screenRenderStarts.delete(screenName);
4379
+ const duration = Date.now() - startTime;
4380
+ track("$screen_render", {
4381
+ screen_name: screenName,
4382
+ duration_ms: duration
4383
+ });
4384
+ return duration;
4385
+ }
4386
+ /**
4387
+ * Track a screen render using a useEffect-friendly pattern.
4388
+ * Returns a cleanup function.
4389
+ */
4390
+ trackScreenRender(screenName) {
4391
+ this.startScreenRender(screenName);
4392
+ return () => {
4393
+ this.endScreenRender(screenName);
4394
+ };
4395
+ }
4396
+ };
4397
+
4398
+ // src/react-native/push.ts
4399
+ var cachedDeviceContext = null;
4400
+ function getEndpoint() {
4401
+ const cfg = sitepong.config;
4402
+ return cfg?.endpoint || "https://ingest.sitepong.com";
4403
+ }
4404
+ function getApiKey() {
4405
+ const cfg = sitepong.config;
4406
+ return cfg?.apiKey || "";
4407
+ }
4408
+ function getDeviceContext() {
4409
+ if (cachedDeviceContext) return cachedDeviceContext;
4390
4410
  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;
4411
+ const info = collectDeviceInfo();
4412
+ cachedDeviceContext = {
4413
+ platform: info.platform,
4414
+ appVersion: info.appVersion,
4415
+ deviceModel: info.deviceModel,
4416
+ osVersion: info.osVersion
4417
+ };
4418
+ fetchPersistentDeviceId().then((id) => {
4419
+ if (id && cachedDeviceContext) cachedDeviceContext.deviceId = id;
4420
+ });
4396
4421
  } catch {
4397
- return void 0;
4422
+ cachedDeviceContext = {};
4398
4423
  }
4424
+ return cachedDeviceContext;
4399
4425
  }
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);
4426
+ async function postToIngest(path, body) {
4427
+ const endpoint = getEndpoint();
4428
+ const apiKey = getApiKey();
4429
+ if (!apiKey) {
4430
+ console.warn("[SitePong] Cannot register push token: SDK not initialized");
4431
+ return;
4432
+ }
4433
+ try {
4434
+ const response = await fetch(`${endpoint}${path}`, {
4435
+ method: "POST",
4436
+ headers: {
4437
+ "Content-Type": "application/json",
4438
+ "X-API-Key": apiKey
4439
+ },
4440
+ body: JSON.stringify(body)
4441
+ });
4442
+ if (!response.ok) {
4443
+ console.warn(`[SitePong] Push token registration failed: HTTP ${response.status}`);
4444
+ }
4445
+ } catch (err) {
4446
+ console.warn("[SitePong] Push token registration failed:", err);
4413
4447
  }
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
4448
  }
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;
4449
+ async function deleteFromIngest(path, body) {
4450
+ const endpoint = getEndpoint();
4451
+ const apiKey = getApiKey();
4452
+ if (!apiKey) return;
4453
+ try {
4454
+ const response = await fetch(`${endpoint}${path}`, {
4455
+ method: "DELETE",
4456
+ headers: {
4457
+ "Content-Type": "application/json",
4458
+ "X-API-Key": apiKey
4459
+ },
4460
+ body: JSON.stringify(body)
4461
+ });
4462
+ if (!response.ok) {
4463
+ console.warn(`[SitePong] Push token deactivation failed: HTTP ${response.status}`);
4430
4464
  }
4431
- visit(f.child);
4432
- visit(f.sibling);
4465
+ } catch (err) {
4466
+ console.warn("[SitePong] Push token deactivation failed:", err);
4467
+ }
4468
+ }
4469
+ function registerPushToken(token, options) {
4470
+ const device = getDeviceContext();
4471
+ postToIngest("/api/push/tokens", {
4472
+ expo_push_token: token,
4473
+ environment: options.environment,
4474
+ device_id: device.deviceId,
4475
+ platform: device.platform,
4476
+ app_version: device.appVersion,
4477
+ device_model: device.deviceModel,
4478
+ os_version: device.osVersion
4479
+ });
4480
+ }
4481
+ function registerDeviceToken(token, options) {
4482
+ const device = getDeviceContext();
4483
+ const tokenType = options.platform === "ios" ? "apns" : "fcm";
4484
+ postToIngest("/api/push/tokens", {
4485
+ native_device_token: token,
4486
+ token_type: tokenType,
4487
+ environment: options.environment,
4488
+ device_id: device.deviceId,
4489
+ platform: options.platform,
4490
+ app_version: device.appVersion,
4491
+ device_model: device.deviceModel,
4492
+ os_version: device.osVersion
4493
+ });
4494
+ }
4495
+ function registerLiveActivityToken(activityType, activityId, pushToken, options) {
4496
+ const device = getDeviceContext();
4497
+ postToIngest("/api/push/live-activity-tokens", {
4498
+ activity_type: activityType,
4499
+ activity_id: activityId,
4500
+ push_token: pushToken,
4501
+ environment: options.environment,
4502
+ device_id: device.deviceId
4503
+ });
4504
+ }
4505
+ function registerPushToStartToken(activityType, pushToStartToken, options) {
4506
+ const device = getDeviceContext();
4507
+ postToIngest("/api/push/push-to-start-tokens", {
4508
+ activity_type: activityType,
4509
+ push_to_start_token: pushToStartToken,
4510
+ environment: options.environment,
4511
+ device_id: device.deviceId
4512
+ });
4513
+ }
4514
+ function endLiveActivity(activityType, activityId) {
4515
+ deleteFromIngest("/api/push/live-activity-tokens", {
4516
+ activity_type: activityType,
4517
+ activity_id: activityId
4518
+ });
4519
+ }
4520
+ var SitePongRNContext = React2.createContext({
4521
+ isInitialized: false,
4522
+ performanceManager: null
4523
+ });
4524
+ function SitePongRNProvider({
4525
+ apiKey,
4526
+ config = {},
4527
+ asyncStorage,
4528
+ navigationRef,
4529
+ trackNetwork = true,
4530
+ trackAppState = true,
4531
+ captureErrors = true,
4532
+ screenRecording,
4533
+ devicePushToken,
4534
+ devicePlatform,
4535
+ pushEnvironment = "production",
4536
+ pushUserId,
4537
+ children
4538
+ }) {
4539
+ const initialized = React2.useRef(false);
4540
+ const [sdkReady, setSdkReady] = React2.useState(false);
4541
+ const performanceManagerRef = React2.useRef(null);
4542
+ React2.useEffect(() => {
4543
+ if (initialized.current) return;
4544
+ const teardowns = [];
4545
+ const deviceInfo = collectDeviceInfo();
4546
+ setRNDeviceInfo({
4547
+ platform: "react-native",
4548
+ osName: deviceInfo.platform,
4549
+ osVersion: deviceInfo.osVersion,
4550
+ deviceName: deviceInfo.deviceName,
4551
+ deviceBrand: deviceInfo.deviceBrand,
4552
+ deviceModel: deviceInfo.deviceModel,
4553
+ appVersion: deviceInfo.appVersion,
4554
+ screenWidth: deviceInfo.screenWidth,
4555
+ screenHeight: deviceInfo.screenHeight,
4556
+ isEmulator: deviceInfo.isEmulator
4557
+ });
4558
+ fetchPersistentDeviceId().then((id) => {
4559
+ if (id) setNativeDeviceId(id);
4560
+ }).catch(() => {
4561
+ });
4562
+ const storageAdapter = asyncStorage ? createAsyncStorageAdapter(asyncStorage) : void 0;
4563
+ initRN({
4564
+ apiKey,
4565
+ ...config,
4566
+ _storageAdapter: storageAdapter
4567
+ });
4568
+ if (captureErrors) {
4569
+ const teardownErrors = setupRNErrorHandler();
4570
+ teardowns.push(teardownErrors);
4571
+ }
4572
+ if (trackNetwork) {
4573
+ const teardownNetwork = setupNetworkInterception();
4574
+ teardowns.push(teardownNetwork);
4575
+ }
4576
+ if (trackAppState) {
4577
+ const autocapture = new RNAutocaptureModule({ trackAppState: true });
4578
+ autocapture.start();
4579
+ teardowns.push(() => autocapture.stop());
4580
+ }
4581
+ if (navigationRef) {
4582
+ const teardownNav = createNavigationTracker(navigationRef);
4583
+ teardowns.push(teardownNav);
4584
+ }
4585
+ if (screenRecording?.enabled) {
4586
+ const sessionId = `rn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
4587
+ const endpoint = config.endpoint || "https://ingest.sitepong.com";
4588
+ startScreenRecordingOnError(apiKey, endpoint, sessionId, screenRecording);
4589
+ teardowns.push(() => stopScreenRecording());
4590
+ }
4591
+ if (devicePushToken && devicePlatform) {
4592
+ registerDeviceToken(devicePushToken, { platform: devicePlatform, environment: pushEnvironment});
4593
+ }
4594
+ performanceManagerRef.current = new RNPerformanceManager();
4595
+ performanceManagerRef.current.trackColdStart();
4596
+ initialized.current = true;
4597
+ setSdkReady(true);
4598
+ return () => {
4599
+ for (const teardown of teardowns) {
4600
+ teardown();
4601
+ }
4602
+ };
4603
+ }, [apiKey]);
4604
+ const value = {
4605
+ isInitialized: sdkReady,
4606
+ performanceManager: performanceManagerRef.current
4433
4607
  };
4434
- visit(fiber.child);
4435
- return out;
4608
+ return /* @__PURE__ */ jsxRuntime.jsx(SitePongRNContext.Provider, { value, children });
4436
4609
  }
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;
4610
+ function useSitePongRNContext() {
4611
+ return React2.useContext(SitePongRNContext);
4612
+ }
4613
+ function useScreenTrack(screenName) {
4614
+ const { performanceManager } = React2.useContext(SitePongRNContext);
4615
+ React2.useEffect(() => {
4616
+ if (!performanceManager) return;
4617
+ performanceManager.startScreenRender(screenName);
4618
+ return () => {
4619
+ performanceManager.endScreenRender(screenName);
4620
+ };
4621
+ }, [screenName, performanceManager]);
4445
4622
  }
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;
4623
+ function useAppState() {
4624
+ const [appState, setAppState] = React2.useState("active");
4625
+ React2.useEffect(() => {
4626
+ setAppState(reactNative.AppState.currentState);
4627
+ const subscription = reactNative.AppState.addEventListener("change", (nextState) => {
4628
+ setAppState(nextState);
4629
+ });
4630
+ return () => subscription.remove();
4631
+ }, []);
4632
+ return appState;
4451
4633
  }
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
- }
4634
+ function useRemoteConfig() {
4635
+ const [config, setConfig] = React2.useState(() => getRemoteConfig());
4636
+ React2.useEffect(() => {
4637
+ setConfig(getRemoteConfig());
4638
+ const unsubscribe = onRemoteConfigChange((newConfig) => {
4639
+ setConfig(newConfig);
4640
+ });
4641
+ return unsubscribe;
4642
+ }, []);
4643
+ return config;
4476
4644
  }
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 };
4645
+ function useRNPerformance(screenName) {
4646
+ const { performanceManager } = React2.useContext(SitePongRNContext);
4647
+ const [duration, setDuration] = React2.useState(null);
4648
+ const startTimeRef = React2.useRef(Date.now());
4649
+ React2.useEffect(() => {
4650
+ startTimeRef.current = Date.now();
4651
+ const handle = requestAnimationFrame(() => {
4652
+ const d = Date.now() - startTimeRef.current;
4653
+ setDuration(d);
4654
+ if (performanceManager) {
4655
+ performanceManager.startScreenRender(screenName);
4656
+ performanceManager.endScreenRender(screenName);
4657
+ }
4658
+ });
4659
+ return () => cancelAnimationFrame(handle);
4660
+ }, [screenName, performanceManager]);
4661
+ return { duration };
4485
4662
  }
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}`;
4663
+ var warnedMissing = false;
4664
+ function loadBridge() {
4665
+ return getScreenRecorderModule();
4491
4666
  }
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));
4667
+ var started = false;
4668
+ function startWatchtower(config) {
4669
+ if (started) return;
4670
+ const bridge = loadBridge();
4671
+ if (!bridge) {
4672
+ if (!warnedMissing) {
4673
+ warnedMissing = true;
4674
+ console.warn(
4675
+ "[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."
4676
+ );
4540
4677
  }
4678
+ return;
4541
4679
  }
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());
4680
+ bridge.watchtowerStart({
4681
+ apiKey: config.apiKey,
4682
+ projectId: config.projectId,
4683
+ endpoint: config.endpoint,
4684
+ sampleRate: config.sampleRate ?? 0.1,
4685
+ platform: config.platform ?? "react-native-ios"
4686
+ });
4687
+ started = true;
4688
+ if (config.distinctId) setWatchtowerUser(config.distinctId);
4689
+ }
4690
+ function setWatchtowerUser(distinctId) {
4691
+ try {
4692
+ loadBridge()?.watchtowerSetUser(distinctId);
4693
+ } catch {
4548
4694
  }
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
- );
4695
+ }
4696
+ function getWatchtowerSessionId() {
4697
+ try {
4698
+ return loadBridge()?.watchtowerGetSessionId() ?? null;
4699
+ } catch {
4700
+ return null;
4567
4701
  }
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));
4702
+ }
4703
+ function stopWatchtower() {
4704
+ if (!started) return;
4705
+ loadBridge()?.watchtowerStop();
4706
+ started = false;
4707
+ }
4708
+ function subscribeNavigationToWatchtower(navigationRef) {
4709
+ const unsubscribe = createNavigationTracker(navigationRef, {
4710
+ screenNameFilter: (name) => {
4711
+ try {
4712
+ loadBridge()?.watchtowerSetScreen(name);
4713
+ } catch {
4714
+ }
4715
+ return name;
4593
4716
  }
4717
+ });
4718
+ try {
4719
+ const route = navigationRef.getCurrentRoute?.();
4720
+ if (route?.name) loadBridge()?.watchtowerSetScreen(route.name);
4721
+ } catch {
4594
4722
  }
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();
4723
+ return unsubscribe;
4724
+ }
4725
+ function WatchtowerProvider(props) {
4726
+ const { config, navigationRef, children } = props;
4727
+ React2__default.default.useEffect(() => {
4728
+ startWatchtower(config);
4729
+ const unsubscribe = subscribeNavigationToWatchtower(navigationRef);
4730
+ return () => {
4731
+ unsubscribe();
4732
+ stopWatchtower();
4733
+ };
4734
+ }, []);
4735
+ return React2__default.default.createElement(React2__default.default.Fragment, null, children);
4736
+ }
4607
4737
  var TAP_MOVE_THRESHOLD_PX = 12;
4608
4738
  function StructuralCaptureProvider({ children, ...cfg }) {
4739
+ const cfgRef = React2.useRef(cfg);
4740
+ cfgRef.current = cfg;
4609
4741
  React2.useEffect(() => {
4610
- structuralCapture.start(cfg);
4742
+ structuralCapture.start(cfgRef.current);
4611
4743
  return () => {
4612
4744
  void structuralCapture.stop();
4613
4745
  };
4614
4746
  }, []);
4615
- const startPos = React2.useRef(null);
4747
+ const gesture = React2.useRef(null);
4616
4748
  const onTouchStart = (e) => {
4617
- const { pageX, pageY } = e.nativeEvent;
4618
- startPos.current = { x: pageX, y: pageY };
4749
+ const ne = e.nativeEvent;
4750
+ gesture.current = {
4751
+ startX: ne.pageX,
4752
+ startY: ne.pageY,
4753
+ lastX: ne.pageX,
4754
+ lastY: ne.pageY,
4755
+ target: Number(ne.target),
4756
+ scrolling: false
4757
+ };
4758
+ };
4759
+ const onTouchMove = (e) => {
4760
+ const g2 = gesture.current;
4761
+ if (!g2) return;
4762
+ const ne = e.nativeEvent;
4763
+ const movedFar = Math.abs(ne.pageX - g2.startX) > TAP_MOVE_THRESHOLD_PX || Math.abs(ne.pageY - g2.startY) > TAP_MOVE_THRESHOLD_PX;
4764
+ if (movedFar) g2.scrolling = true;
4765
+ if (g2.scrolling) {
4766
+ const dx = g2.lastX - ne.pageX;
4767
+ const dy = g2.lastY - ne.pageY;
4768
+ structuralCapture.handleScroll(g2.target, dx, dy);
4769
+ }
4770
+ g2.lastX = ne.pageX;
4771
+ g2.lastY = ne.pageY;
4619
4772
  };
4620
4773
  const onTouchEnd = (e) => {
4774
+ const g2 = gesture.current;
4775
+ gesture.current = null;
4776
+ if (!g2) return;
4777
+ if (g2.scrolling) return;
4621
4778
  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) {
4779
+ if (Math.abs(ne.pageX - g2.startX) > TAP_MOVE_THRESHOLD_PX || Math.abs(ne.pageY - g2.startY) > TAP_MOVE_THRESHOLD_PX) {
4626
4780
  return;
4627
4781
  }
4628
- structuralCapture.handleTap(Number(ne.target), ne.pageX, ne.pageY);
4782
+ structuralCapture.handleTap(g2.target, ne.pageX, ne.pageY);
4629
4783
  };
4630
- return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: { flex: 1 }, onTouchStart, onTouchEnd, children });
4784
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: { flex: 1 }, onTouchStart, onTouchMove, onTouchEnd, children });
4631
4785
  }
4632
4786
  function useStructuralScreen(name) {
4633
4787
  React2.useEffect(() => {