@dynamic-labs/react-native-extension 4.83.1 → 4.83.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/android/EmbeddedWebViewController.kt +26 -0
  2. package/android/EmbeddedWebViewModule.kt +8 -1
  3. package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  4. package/android/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  5. package/android/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  6. package/android/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  7. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  8. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  9. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  10. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  11. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  12. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  13. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  14. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  15. package/index.cjs +350 -24
  16. package/index.js +351 -25
  17. package/ios/EmbeddedWebViewController.swift +22 -0
  18. package/ios/EmbeddedWebViewModule.swift +8 -1
  19. package/package.json +6 -6
  20. package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +20 -1
  21. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/embeddedWebViewPhaseTimers.d.ts +52 -0
  22. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/index.d.ts +1 -0
  23. package/src/components/WebView/useWebViewPhaseTimers/index.d.ts +1 -0
  24. package/src/components/WebView/useWebViewPhaseTimers/useWebViewPhaseTimers.d.ts +45 -0
  25. package/src/nativeModules/EmbeddedWebView.d.ts +27 -2
package/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { assertPackageVersion } from '@dynamic-labs/assert-package-version';
2
2
  import { StyleSheet, Platform } from 'react-native';
3
- import { useEffect, useRef, useState, useCallback } from 'react';
3
+ import { useEffect, useRef, useCallback, useState } from 'react';
4
4
  import { WebView as WebView$1 } from 'react-native-webview';
5
5
  import { Logger } from '@dynamic-labs/logger';
6
6
  import { messageTransportDataJsonReviver, parseMessageTransportData, messageTransportDataJsonReplacer, createRequestChannel } from '@dynamic-labs/message-transport';
7
+ import { sdkHasLoadedEventName } from '@dynamic-labs/webview-messages';
7
8
  import { jsx } from 'react/jsx-runtime';
8
9
  import { requireNativeModule } from 'expo-modules-core';
9
10
  import { Passkey } from 'react-native-passkey';
@@ -12,7 +13,7 @@ import { openAuthSessionAsync } from 'expo-web-browser';
12
13
  import { getItemAsync, deleteItemAsync, setItemAsync } from 'expo-secure-store';
13
14
  import { createPasskey, PasskeyStamper } from '@turnkey/react-native-passkey-stamper';
14
15
 
15
- var version = "4.83.1";
16
+ var version = "4.83.2-alpha.0";
16
17
 
17
18
  function _extends() {
18
19
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -100,6 +101,122 @@ const useMessageTransportWebViewBridge = (core, webViewRef) => {
100
101
  };
101
102
  };
102
103
 
104
+ const CLEAR_STATE_PARAM = 'clear-state';
105
+ const ENVIRONMENT_ID_PARAM = 'environmentId';
106
+ const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
107
+
108
+ const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
109
+
110
+ const MANIFEST_MESSAGE_TYPE$1 = 'manifest';
111
+ const RETRY_QUERY_PARAM = 'retry';
112
+ const emptyPerAttemptState$1 = () => ({
113
+ htmlLoadStartedAt: null,
114
+ htmlLoadedAt: null,
115
+ manifestReceivedAt: null,
116
+ onLoadEndAt: null,
117
+ sdkReadyAt: null
118
+ });
119
+ const diffMs$1 = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
120
+ const readRetryCount = webViewUrl => {
121
+ const raw = webViewUrl.searchParams.get(RETRY_QUERY_PARAM);
122
+ if (raw === null) return 0;
123
+ const parsed = Number(raw);
124
+ return Number.isFinite(parsed) ? parsed : 0;
125
+ };
126
+ /**
127
+ * Tracks how long each step of the WebView load took, so when we raise
128
+ * `WebViewFailedToLoadError` we can attach the timings to the error log.
129
+ *
130
+ * The timings cover the host-side phases of the load — the webview-side
131
+ * timings (`webview.time_to_load_manifest`, `webview.time_to_sdk_ready`)
132
+ * still come from the webview itself once it boots, but those don't help
133
+ * when the webview never boots in the first place.
134
+ */
135
+ const useWebViewPhaseTimers = ({
136
+ core,
137
+ webViewUrl,
138
+ loadingTimeout,
139
+ recoveryTimeout
140
+ }) => {
141
+ const perAttemptRef = useRef(emptyPerAttemptState$1());
142
+ const nativeErrorCountRef = useRef(0);
143
+ const osKillCountRef = useRef(0);
144
+ const webViewUrlRef = useRef(webViewUrl);
145
+ webViewUrlRef.current = webViewUrl;
146
+ /**
147
+ * Listen on the message transport for the two webview-originated
148
+ * messages that mark "JS bundle alive" and "SDK ready".
149
+ *
150
+ * We only record the first occurrence per attempt so that a healthy
151
+ * reload doesn't overwrite the timing from the attempt that ultimately
152
+ * failed.
153
+ */
154
+ useEffect(() => {
155
+ const handler = message => {
156
+ if (message.origin !== 'webview') return;
157
+ if (message.type === MANIFEST_MESSAGE_TYPE$1 && perAttemptRef.current.manifestReceivedAt === null) {
158
+ perAttemptRef.current.manifestReceivedAt = Date.now();
159
+ }
160
+ if (message.type === sdkHasLoadedEventName && perAttemptRef.current.sdkReadyAt === null) {
161
+ perAttemptRef.current.sdkReadyAt = Date.now();
162
+ }
163
+ };
164
+ core.messageTransport.on(handler);
165
+ return () => core.messageTransport.off(handler);
166
+ }, [core]);
167
+ const recordEvent = useCallback(event => {
168
+ const now = Date.now();
169
+ switch (event) {
170
+ case 'load_start':
171
+ perAttemptRef.current = emptyPerAttemptState$1();
172
+ perAttemptRef.current.htmlLoadStartedAt = now;
173
+ break;
174
+ case 'load':
175
+ perAttemptRef.current.htmlLoadedAt = now;
176
+ break;
177
+ case 'load_end':
178
+ perAttemptRef.current.onLoadEndAt = now;
179
+ break;
180
+ case 'native_error':
181
+ nativeErrorCountRef.current += 1;
182
+ break;
183
+ case 'os_kill':
184
+ osKillCountRef.current += 1;
185
+ break;
186
+ }
187
+ }, []);
188
+ const getMeta = useCallback(({
189
+ phase
190
+ }) => {
191
+ const url = webViewUrlRef.current;
192
+ const {
193
+ htmlLoadStartedAt,
194
+ htmlLoadedAt,
195
+ onLoadEndAt,
196
+ manifestReceivedAt,
197
+ sdkReadyAt
198
+ } = perAttemptRef.current;
199
+ return {
200
+ hadClearState: hasClearStateInUrl(url),
201
+ htmlLoadMs: diffMs$1(htmlLoadedAt, htmlLoadStartedAt),
202
+ loadingTimeoutMs: loadingTimeout,
203
+ manifestReceivedMs: diffMs$1(manifestReceivedAt, onLoadEndAt),
204
+ nativeErrorCount: nativeErrorCountRef.current,
205
+ onLoadToOnLoadEndMs: diffMs$1(onLoadEndAt, htmlLoadedAt),
206
+ osKillCount: osKillCountRef.current,
207
+ phase,
208
+ recoveryTimeoutMs: recoveryTimeout,
209
+ retryCount: readRetryCount(url),
210
+ sdkReadyMs: diffMs$1(sdkReadyAt, onLoadEndAt),
211
+ webviewUrl: url.toString()
212
+ };
213
+ }, [loadingTimeout, recoveryTimeout]);
214
+ return {
215
+ getMeta,
216
+ recordEvent
217
+ };
218
+ };
219
+
103
220
  const useWebViewVisibility = core => {
104
221
  const webViewVisibilityRequestChannelRef = useRef(createRequestChannel(core.messageTransport));
105
222
  const [visible, setVisible] = useState(false);
@@ -136,10 +253,6 @@ const styles = StyleSheet.create({
136
253
  }
137
254
  });
138
255
 
139
- const CLEAR_STATE_PARAM = 'clear-state';
140
- const ENVIRONMENT_ID_PARAM = 'environmentId';
141
- const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
142
-
143
256
  const setClearStateToUrl = webViewUrl => {
144
257
  const newWebViewUrl = new URL(webViewUrl);
145
258
  newWebViewUrl.searchParams.set(CLEAR_STATE_PARAM, 'true');
@@ -147,8 +260,6 @@ const setClearStateToUrl = webViewUrl => {
147
260
  return newWebViewUrl;
148
261
  };
149
262
 
150
- const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
151
-
152
263
  const useWebViewRecoveryTimeout = ({
153
264
  core,
154
265
  webViewUrl,
@@ -301,15 +412,15 @@ const shouldAllowNavigation = (request, webViewUrl) => {
301
412
  return false;
302
413
  };
303
414
 
304
- const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
305
- const DEFAULT_LOADING_TIMEOUT_MS = 20000;
415
+ const DEFAULT_RECOVERY_TIMEOUT_MS$1 = 20000;
416
+ const DEFAULT_LOADING_TIMEOUT_MS$1 = 20000;
306
417
  const WebView = ({
307
418
  webviewUrl: initialWebViewUrl,
308
419
  core,
309
420
  webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
310
421
  disableRecovery: _disableRecovery = false,
311
- recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS,
312
- loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS
422
+ recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS$1,
423
+ loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS$1
313
424
  }) => {
314
425
  const webViewRef = useRef(null);
315
426
  const {
@@ -319,6 +430,15 @@ const WebView = ({
319
430
  const {
320
431
  onMessageHandler
321
432
  } = useMessageTransportWebViewBridge(core, webViewRef);
433
+ const {
434
+ recordEvent,
435
+ getMeta
436
+ } = useWebViewPhaseTimers({
437
+ core,
438
+ loadingTimeout: _loadingTimeout,
439
+ recoveryTimeout: _recoveryTimeout,
440
+ webViewUrl
441
+ });
322
442
  const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
323
443
  /**
324
444
  * Block the message transport when the webview is unmounted
@@ -360,13 +480,23 @@ const WebView = ({
360
480
  return newWebViewUrl;
361
481
  });
362
482
  }, [core]);
363
- const blockAndReloadWebViewOsKill = useCallback(() => blockAndReloadWebView('os_kill'), [blockAndReloadWebView]);
483
+ const blockAndReloadWebViewOsKill = useCallback(() => {
484
+ recordEvent('os_kill');
485
+ blockAndReloadWebView('os_kill');
486
+ }, [blockAndReloadWebView, recordEvent]);
364
487
  useEffect(() => core.messageTransport.recoveryManager.onRecoveryRequested(() => blockAndReloadWebView('recovery')), [core, blockAndReloadWebView]);
365
- const setWebViewLoadError = useCallback(() => {
488
+ const setWebViewLoadError = useCallback(phase => {
366
489
  // Error was already thrown, do not throw again
367
490
  if (core.initialization.error instanceof WebViewFailedToLoadError) return;
368
- core.initialization.error = new WebViewFailedToLoadError();
369
- }, [core]);
491
+ core.initialization.error = new WebViewFailedToLoadError(getMeta({
492
+ phase
493
+ }));
494
+ }, [core, getMeta]);
495
+ /**
496
+ * Wraps `setWebViewLoadError` so the consumer doesn't need to know
497
+ * about the phase enum; each callsite below binds its own phase.
498
+ */
499
+ const setWebViewLoadErrorWithPhase = useCallback(phase => () => setWebViewLoadError(phase), [setWebViewLoadError]);
370
500
  /**
371
501
  * Reload the webview with a clean state when a timeout is reached
372
502
  * and the webview did not get to the loaded state yet
@@ -374,7 +504,7 @@ const WebView = ({
374
504
  const startRecoveryTimeout = useWebViewRecoveryTimeout({
375
505
  core,
376
506
  disableRecovery: _disableRecovery,
377
- onFailedToLoadAfterClearState: setWebViewLoadError,
507
+ onFailedToLoadAfterClearState: setWebViewLoadErrorWithPhase('after_clear_state'),
378
508
  recoveryTimeout: _recoveryTimeout,
379
509
  setWebViewUrl,
380
510
  webViewUrl
@@ -382,17 +512,30 @@ const WebView = ({
382
512
  const webViewLoadErrorCountRef = useRef(0);
383
513
  const onWebViewLoadError = useCallback(() => {
384
514
  webViewLoadErrorCountRef.current = webViewLoadErrorCountRef.current + 1;
515
+ recordEvent('native_error');
385
516
  /**
386
517
  * This is the first attempt to load the webview, do not throw an error
387
518
  * because the recovery system will attempt to reload the webview
388
519
  */
389
520
  if (webViewLoadErrorCountRef.current === 1) return;
390
- setWebViewLoadError();
391
- }, [setWebViewLoadError]);
521
+ setWebViewLoadError('native_error');
522
+ }, [recordEvent, setWebViewLoadError]);
392
523
  const {
393
- onLoad,
394
- onLoadStart
395
- } = useWebViewLoadingTimeout(_loadingTimeout, setWebViewLoadError);
524
+ onLoad: onLoadFromTimeout,
525
+ onLoadStart: onLoadStartFromTimeout
526
+ } = useWebViewLoadingTimeout(_loadingTimeout, setWebViewLoadErrorWithPhase('html_load'));
527
+ const onLoadStart = useCallback(() => {
528
+ recordEvent('load_start');
529
+ onLoadStartFromTimeout();
530
+ }, [onLoadStartFromTimeout, recordEvent]);
531
+ const onLoad = useCallback(() => {
532
+ recordEvent('load');
533
+ onLoadFromTimeout();
534
+ }, [onLoadFromTimeout, recordEvent]);
535
+ const onLoadEnd = useCallback(() => {
536
+ recordEvent('load_end');
537
+ startRecoveryTimeout();
538
+ }, [recordEvent, startRecoveryTimeout]);
396
539
  return /*#__PURE__*/jsx(WebView$1, {
397
540
  ref: webViewRef,
398
541
  source: {
@@ -401,7 +544,7 @@ const WebView = ({
401
544
  containerStyle: containerStyles,
402
545
  style: styles['webview'],
403
546
  onMessage: onMessageHandler,
404
- onLoadEnd: () => startRecoveryTimeout(),
547
+ onLoadEnd: onLoadEnd,
405
548
  onLoadStart: onLoadStart,
406
549
  onLoad: onLoad,
407
550
  hideKeyboardAccessoryView: true,
@@ -453,6 +596,105 @@ const getEmbeddedWebView = () => {
453
596
  }
454
597
  };
455
598
 
599
+ const MANIFEST_MESSAGE_TYPE = 'manifest';
600
+ const emptyPerAttemptState = () => ({
601
+ htmlLoadStartedAt: null,
602
+ htmlLoadedAt: null,
603
+ manifestReceivedAt: null,
604
+ onLoadEndAt: null,
605
+ sdkReadyAt: null
606
+ });
607
+ const diffMs = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
608
+ /**
609
+ * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
610
+ * webview path. The embedded path is a singleton wired by `setupEmbeddedWebView`
611
+ * (not a React component), so we expose the same phase-timing surface as a
612
+ * plain factory.
613
+ *
614
+ * The hook and the factory are intentionally separate: the embedded path does
615
+ * not have a retry or clear-state model, so `retryCount` is always `0` and
616
+ * `hadClearState` is always `false` in the meta produced here. Sharing one
617
+ * implementation would require either pulling React into a hot non-React path
618
+ * or threading a stateless adapter through both consumers \u2014 the duplication
619
+ * is small enough that keeping them separate stays cleaner.
620
+ */
621
+ const createEmbeddedWebViewPhaseTimers = ({
622
+ core,
623
+ webViewUrl,
624
+ loadingTimeoutMs,
625
+ recoveryTimeoutMs
626
+ }) => {
627
+ let perAttempt = emptyPerAttemptState();
628
+ let nativeErrorCount = 0;
629
+ let osKillCount = 0;
630
+ const handleMessage = message => {
631
+ if (message.origin !== 'webview') return;
632
+ if (message.type === MANIFEST_MESSAGE_TYPE && perAttempt.manifestReceivedAt === null) {
633
+ perAttempt.manifestReceivedAt = Date.now();
634
+ }
635
+ if (message.type === sdkHasLoadedEventName && perAttempt.sdkReadyAt === null) {
636
+ perAttempt.sdkReadyAt = Date.now();
637
+ }
638
+ };
639
+ core.messageTransport.on(handleMessage);
640
+ const recordEvent = event => {
641
+ const now = Date.now();
642
+ switch (event) {
643
+ case 'load_start':
644
+ perAttempt = emptyPerAttemptState();
645
+ perAttempt.htmlLoadStartedAt = now;
646
+ break;
647
+ case 'load':
648
+ perAttempt.htmlLoadedAt = now;
649
+ break;
650
+ case 'load_end':
651
+ perAttempt.onLoadEndAt = now;
652
+ break;
653
+ case 'native_error':
654
+ nativeErrorCount += 1;
655
+ break;
656
+ case 'os_kill':
657
+ osKillCount += 1;
658
+ break;
659
+ }
660
+ };
661
+ const getMeta = ({
662
+ phase
663
+ }) => {
664
+ const {
665
+ htmlLoadStartedAt,
666
+ htmlLoadedAt,
667
+ onLoadEndAt,
668
+ manifestReceivedAt,
669
+ sdkReadyAt
670
+ } = perAttempt;
671
+ return {
672
+ hadClearState: false,
673
+ htmlLoadMs: diffMs(htmlLoadedAt, htmlLoadStartedAt),
674
+ loadingTimeoutMs,
675
+ manifestReceivedMs: diffMs(manifestReceivedAt, onLoadEndAt),
676
+ nativeErrorCount,
677
+ onLoadToOnLoadEndMs: diffMs(onLoadEndAt, htmlLoadedAt),
678
+ osKillCount,
679
+ phase,
680
+ recoveryTimeoutMs,
681
+ retryCount: 0,
682
+ sdkReadyMs: diffMs(sdkReadyAt, onLoadEndAt),
683
+ webviewUrl: webViewUrl.toString()
684
+ };
685
+ };
686
+ const dispose = () => {
687
+ core.messageTransport.off(handleMessage);
688
+ };
689
+ return {
690
+ dispose,
691
+ getMeta,
692
+ recordEvent
693
+ };
694
+ };
695
+
696
+ const DEFAULT_LOADING_TIMEOUT_MS = 20000;
697
+ const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
456
698
  // Wires the JS-side bridge to the native WKWebView singleton owned by Swift.
457
699
  // This is intentionally not a React component: the embedded webview lives
458
700
  // outside the React tree (in a dedicated UIWindow on iOS), so its setup runs
@@ -465,10 +707,47 @@ const getEmbeddedWebView = () => {
465
707
  const setupEmbeddedWebView = ({
466
708
  webviewUrl,
467
709
  core,
468
- webviewDebuggingEnabled: _webviewDebuggingEnabled = false
710
+ webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
711
+ loadingTimeoutMs: _loadingTimeoutMs = DEFAULT_LOADING_TIMEOUT_MS,
712
+ recoveryTimeoutMs: _recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS
469
713
  }) => {
470
714
  const native = getEmbeddedWebView();
471
715
  const builtUrl = assignStartTimeToUrl(assignEnvironmentIdToUrl(webviewUrl, core.environmentId));
716
+ const phaseTimers = createEmbeddedWebViewPhaseTimers({
717
+ core,
718
+ loadingTimeoutMs: _loadingTimeoutMs,
719
+ recoveryTimeoutMs: _recoveryTimeoutMs,
720
+ webViewUrl: builtUrl
721
+ });
722
+ let loadingTimer = null;
723
+ let recoveryTimer = null;
724
+ let hasFailed = false;
725
+ const clearLoadingTimer = () => {
726
+ if (loadingTimer !== null) {
727
+ clearTimeout(loadingTimer);
728
+ loadingTimer = null;
729
+ }
730
+ };
731
+ const clearRecoveryTimer = () => {
732
+ if (recoveryTimer !== null) {
733
+ clearTimeout(recoveryTimer);
734
+ recoveryTimer = null;
735
+ }
736
+ };
737
+ // Raise `WebViewFailedToLoadError` once per setup. Subsequent triggers
738
+ // (e.g. recovery timeout firing after html_load already failed) are
739
+ // suppressed so we don't spam the initialization-error setter with
740
+ // duplicate logs for the same load attempt.
741
+ const raiseFailure = (phase, extraMeta = {}) => {
742
+ if (hasFailed) return;
743
+ hasFailed = true;
744
+ clearLoadingTimer();
745
+ clearRecoveryTimer();
746
+ const meta = phaseTimers.getMeta({
747
+ phase
748
+ });
749
+ core.initialization.error = new WebViewFailedToLoadError(Object.assign(Object.assign({}, meta), extraMeta));
750
+ };
472
751
  const visibilityChannel = createRequestChannel(core.messageTransport);
473
752
  const removeVisibilityHandler = visibilityChannel.handle('setVisibility', visible => {
474
753
  native.setVisible(visible).catch(err => {
@@ -482,6 +761,15 @@ const setupEmbeddedWebView = ({
482
761
  });
483
762
  };
484
763
  core.messageTransport.on(handleHostMessage);
764
+ // Watch for the webview-side SDK ready signal so we can clear the
765
+ // recovery timer. The phase-timer factory also subscribes for its own
766
+ // bookkeeping — both subscriptions are independent and idempotent.
767
+ const handleSdkReady = message => {
768
+ if (message.origin !== 'webview') return;
769
+ if (message.type !== sdkHasLoadedEventName) return;
770
+ clearRecoveryTimer();
771
+ };
772
+ core.messageTransport.on(handleSdkReady);
485
773
  const onMessageSub = native.addListener('onMessage', event => {
486
774
  let parsed = null;
487
775
  try {
@@ -504,7 +792,32 @@ const setupEmbeddedWebView = ({
504
792
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
505
793
  });
506
794
  });
795
+ const onLoadStartSub = native.addListener('onLoadStart', () => {
796
+ phaseTimers.recordEvent('load_start');
797
+ // Re-arm both timers on every load_start. A retry from the native
798
+ // side (e.g. a redirect, or a reload after a transient failure)
799
+ // resets the html_load window cleanly without leaving a stale timer
800
+ // around from the previous attempt.
801
+ clearLoadingTimer();
802
+ clearRecoveryTimer();
803
+ loadingTimer = setTimeout(() => {
804
+ raiseFailure('html_load');
805
+ }, _loadingTimeoutMs);
806
+ });
807
+ const onLoadSub = native.addListener('onLoad', () => {
808
+ phaseTimers.recordEvent('load');
809
+ clearLoadingTimer();
810
+ });
811
+ const onLoadEndSub = native.addListener('onLoadEnd', () => {
812
+ phaseTimers.recordEvent('load_end');
813
+ clearLoadingTimer();
814
+ clearRecoveryTimer();
815
+ recoveryTimer = setTimeout(() => {
816
+ raiseFailure('sdk_bootstrap');
817
+ }, _recoveryTimeoutMs);
818
+ });
507
819
  const onLoadErrorSub = native.addListener('onLoadError', event => {
820
+ phaseTimers.recordEvent('native_error');
508
821
  logger.warn('EmbeddedWebView load error', event);
509
822
  logger.instrument('Embedded webview load error', {
510
823
  environmentId: core.environmentId,
@@ -518,7 +831,13 @@ const setupEmbeddedWebView = ({
518
831
  time: 0,
519
832
  webviewUrl: builtUrl.toString()
520
833
  });
521
- core.initialization.error = new WebViewFailedToLoadError();
834
+ raiseFailure('embedded_native_error', {
835
+ nativeErrorCode: event.code,
836
+ nativeErrorDescription: event.description,
837
+ nativeErrorDomain: event.domain,
838
+ nativeErrorFailedUrl: event.url,
839
+ nativeErrorIsProvisional: event.isProvisional
840
+ });
522
841
  });
523
842
  native.setDebuggingEnabled(_webviewDebuggingEnabled).catch(err => {
524
843
  logger.warn('EmbeddedWebView.setDebuggingEnabled failed', err);
@@ -527,11 +846,18 @@ const setupEmbeddedWebView = ({
527
846
  logger.warn('EmbeddedWebView.setUrl failed', err);
528
847
  });
529
848
  return () => {
849
+ clearLoadingTimer();
850
+ clearRecoveryTimer();
530
851
  removeVisibilityHandler();
531
852
  core.messageTransport.off(handleHostMessage);
853
+ core.messageTransport.off(handleSdkReady);
532
854
  onMessageSub.remove();
533
855
  onShouldStartLoadSub.remove();
856
+ onLoadStartSub.remove();
857
+ onLoadSub.remove();
858
+ onLoadEndSub.remove();
534
859
  onLoadErrorSub.remove();
860
+ phaseTimers.dispose();
535
861
  };
536
862
  };
537
863
 
@@ -380,12 +380,34 @@ extension EmbeddedWebViewController: WKNavigationDelegate {
380
380
  emitLoadError(error: error as NSError, isProvisional: false, url: webView.url?.absoluteString)
381
381
  }
382
382
 
383
+ public func webView(
384
+ _ webView: WKWebView,
385
+ didStartProvisionalNavigation _: WKNavigation!
386
+ ) {
387
+ eventEmitter?("onLoadStart", [
388
+ "url": webView.url?.absoluteString ?? "",
389
+ ])
390
+ }
391
+
383
392
  public func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
384
393
  // WKContentView is created lazily once content starts being committed —
385
394
  // suppress its input accessory bar here so focusing inputs in the page
386
395
  // doesn't show the system "< > Done" toolbar. Matches the
387
396
  // `hideKeyboardAccessoryView` setting used by `react-native-webview`.
388
397
  hideInputAccessoryView(on: webView)
398
+
399
+ // didCommit fires when the first byte of the response is rendered —
400
+ // analogous to RN's `onLoad` callback (page content has actually
401
+ // started arriving, not just the navigation request).
402
+ eventEmitter?("onLoad", [
403
+ "url": webView.url?.absoluteString ?? "",
404
+ ])
405
+ }
406
+
407
+ public func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
408
+ eventEmitter?("onLoadEnd", [
409
+ "url": webView.url?.absoluteString ?? "",
410
+ ])
389
411
  }
390
412
 
391
413
  public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
@@ -6,7 +6,14 @@ public class EmbeddedWebViewModule: Module {
6
6
  public func definition() -> ModuleDefinition {
7
7
  Name("EmbeddedWebView")
8
8
 
9
- Events("onMessage", "onShouldStartLoad", "onLoadError")
9
+ Events(
10
+ "onMessage",
11
+ "onShouldStartLoad",
12
+ "onLoadError",
13
+ "onLoadStart",
14
+ "onLoad",
15
+ "onLoadEnd"
16
+ )
10
17
 
11
18
  OnCreate {
12
19
  self.emitterToken = EmbeddedWebViewController.shared.setEmitter { [weak self] name, payload in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamic-labs/react-native-extension",
3
- "version": "4.83.1",
3
+ "version": "4.83.2-alpha.0",
4
4
  "main": "./index.cjs",
5
5
  "module": "./index.js",
6
6
  "types": "./src/index.d.ts",
@@ -18,11 +18,11 @@
18
18
  "@turnkey/react-native-passkey-stamper": "1.2.7",
19
19
  "@react-native-documents/picker": "^11.0.0",
20
20
  "react-native-fs": ">=2.20.0",
21
- "@dynamic-labs/assert-package-version": "4.83.1",
22
- "@dynamic-labs/client": "4.83.1",
23
- "@dynamic-labs/logger": "4.83.1",
24
- "@dynamic-labs/message-transport": "4.83.1",
25
- "@dynamic-labs/webview-messages": "4.83.1"
21
+ "@dynamic-labs/assert-package-version": "4.83.2-alpha.0",
22
+ "@dynamic-labs/client": "4.83.2-alpha.0",
23
+ "@dynamic-labs/logger": "4.83.2-alpha.0",
24
+ "@dynamic-labs/message-transport": "4.83.2-alpha.0",
25
+ "@dynamic-labs/webview-messages": "4.83.2-alpha.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=18.0.0 <20.0.0",
@@ -3,5 +3,24 @@ export type SetupEmbeddedWebViewArgs = {
3
3
  webviewUrl: string;
4
4
  core: Core;
5
5
  webviewDebuggingEnabled?: boolean;
6
+ /**
7
+ * Max time (ms) between the native webview reporting it has started a
8
+ * navigation (`onLoadStart`) and reporting that the response committed
9
+ * (`onLoad`). If this elapses, the SDK raises
10
+ * {@link WebViewFailedToLoadError} with `phase: 'html_load'` so the page
11
+ * can no longer hang silently waiting for bytes that never arrive.
12
+ *
13
+ * Defaults to 20s, matching the RN `<WebView>` path.
14
+ */
15
+ loadingTimeoutMs?: number;
16
+ /**
17
+ * Max time (ms) between the native webview reporting it has finished
18
+ * loading (`onLoadEnd`) and the SDK signalling it has finished booting
19
+ * (`sdkHasLoadedEventName`). If this elapses, the SDK raises
20
+ * {@link WebViewFailedToLoadError} with `phase: 'sdk_bootstrap'`.
21
+ *
22
+ * Defaults to 20s, matching the RN `<WebView>` path.
23
+ */
24
+ recoveryTimeoutMs?: number;
6
25
  };
7
- export declare const setupEmbeddedWebView: ({ webviewUrl, core, webviewDebuggingEnabled, }: SetupEmbeddedWebViewArgs) => (() => void);
26
+ export declare const setupEmbeddedWebView: ({ webviewUrl, core, webviewDebuggingEnabled, loadingTimeoutMs, recoveryTimeoutMs, }: SetupEmbeddedWebViewArgs) => (() => void);
@@ -0,0 +1,52 @@
1
+ import { Core } from '@dynamic-labs/client';
2
+ import { WebViewFailedToLoadErrorMeta, WebViewFailedToLoadErrorPhase } from '../../../../errors/WebViewFailedToLoadError';
3
+ /**
4
+ * Per-attempt lifecycle event recorded by
5
+ * {@link createEmbeddedWebViewPhaseTimers}. `load_start` resets the per-attempt
6
+ * state (so the timings reflect the current load); `native_error` and
7
+ * `os_kill` increment cumulative counters that persist across reloads.
8
+ */
9
+ export type EmbeddedWebViewPhaseEvent = 'load_start' | 'load' | 'load_end' | 'native_error' | 'os_kill';
10
+ type EmbeddedWebViewPhaseTimersProps = {
11
+ core: Core;
12
+ webViewUrl: URL;
13
+ loadingTimeoutMs: number;
14
+ recoveryTimeoutMs: number;
15
+ };
16
+ export type EmbeddedWebViewPhaseTimers = {
17
+ /**
18
+ * Record a per-attempt lifecycle event. See {@link EmbeddedWebViewPhaseEvent}
19
+ * for the semantics of each event.
20
+ */
21
+ recordEvent: (event: EmbeddedWebViewPhaseEvent) => void;
22
+ /**
23
+ * Build the structured meta to attach to
24
+ * {@link WebViewFailedToLoadError}. The `phase` argument reflects the
25
+ * source of the failure; the duration fields come from the recorded
26
+ * lifecycle events and observed message-transport activity.
27
+ */
28
+ getMeta: (args: {
29
+ phase: WebViewFailedToLoadErrorPhase;
30
+ }) => WebViewFailedToLoadErrorMeta;
31
+ /**
32
+ * Detach from the message transport. Call this on teardown so the
33
+ * embedded webview controller can be re-created without leaking
34
+ * subscriptions.
35
+ */
36
+ dispose: () => void;
37
+ };
38
+ /**
39
+ * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
40
+ * webview path. The embedded path is a singleton wired by `setupEmbeddedWebView`
41
+ * (not a React component), so we expose the same phase-timing surface as a
42
+ * plain factory.
43
+ *
44
+ * The hook and the factory are intentionally separate: the embedded path does
45
+ * not have a retry or clear-state model, so `retryCount` is always `0` and
46
+ * `hadClearState` is always `false` in the meta produced here. Sharing one
47
+ * implementation would require either pulling React into a hot non-React path
48
+ * or threading a stateless adapter through both consumers \u2014 the duplication
49
+ * is small enough that keeping them separate stays cleaner.
50
+ */
51
+ export declare const createEmbeddedWebViewPhaseTimers: ({ core, webViewUrl, loadingTimeoutMs, recoveryTimeoutMs, }: EmbeddedWebViewPhaseTimersProps) => EmbeddedWebViewPhaseTimers;
52
+ export {};
@@ -0,0 +1 @@
1
+ export { createEmbeddedWebViewPhaseTimers, type EmbeddedWebViewPhaseEvent, type EmbeddedWebViewPhaseTimers, } from './embeddedWebViewPhaseTimers';
@@ -0,0 +1 @@
1
+ export { useWebViewPhaseTimers, type WebViewPhaseTimers, } from './useWebViewPhaseTimers';