@dynamic-labs/react-native-extension 4.83.0 → 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 (33) hide show
  1. package/android/EmbeddedWebViewController.kt +26 -0
  2. package/android/EmbeddedWebViewModule.kt +8 -1
  3. package/android/KeyStoreKeyManager.kt +7 -1
  4. package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  5. package/android/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  6. package/android/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
  7. package/android/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  8. package/android/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  9. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  10. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  11. package/android/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
  12. package/android/keychain/KeyStoreKeyManager.kt +7 -1
  13. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  14. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  15. package/android/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
  16. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  17. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  18. package/android/src/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
  19. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  20. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  21. package/android/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
  22. package/index.cjs +352 -23
  23. package/index.js +353 -24
  24. package/ios/EmbeddedWebViewController.swift +22 -0
  25. package/ios/EmbeddedWebViewModule.swift +8 -1
  26. package/package.json +6 -6
  27. package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +20 -1
  28. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/embeddedWebViewPhaseTimers.d.ts +52 -0
  29. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/index.d.ts +1 -0
  30. package/src/components/WebView/useWebViewPhaseTimers/index.d.ts +1 -0
  31. package/src/components/WebView/useWebViewPhaseTimers/useWebViewPhaseTimers.d.ts +45 -0
  32. package/src/errors/WebViewFailedToLoadError.d.ts +84 -1
  33. 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.0";
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) {
@@ -25,8 +26,9 @@ function _extends() {
25
26
  }
26
27
 
27
28
  class WebViewFailedToLoadError extends Error {
28
- constructor() {
29
+ constructor(meta) {
29
30
  super('Could not load Dynamic WebView');
31
+ this.meta = meta;
30
32
  }
31
33
  }
32
34
 
@@ -99,6 +101,122 @@ const useMessageTransportWebViewBridge = (core, webViewRef) => {
99
101
  };
100
102
  };
101
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
+
102
220
  const useWebViewVisibility = core => {
103
221
  const webViewVisibilityRequestChannelRef = useRef(createRequestChannel(core.messageTransport));
104
222
  const [visible, setVisible] = useState(false);
@@ -135,10 +253,6 @@ const styles = StyleSheet.create({
135
253
  }
136
254
  });
137
255
 
138
- const CLEAR_STATE_PARAM = 'clear-state';
139
- const ENVIRONMENT_ID_PARAM = 'environmentId';
140
- const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
141
-
142
256
  const setClearStateToUrl = webViewUrl => {
143
257
  const newWebViewUrl = new URL(webViewUrl);
144
258
  newWebViewUrl.searchParams.set(CLEAR_STATE_PARAM, 'true');
@@ -146,8 +260,6 @@ const setClearStateToUrl = webViewUrl => {
146
260
  return newWebViewUrl;
147
261
  };
148
262
 
149
- const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
150
-
151
263
  const useWebViewRecoveryTimeout = ({
152
264
  core,
153
265
  webViewUrl,
@@ -300,13 +412,15 @@ const shouldAllowNavigation = (request, webViewUrl) => {
300
412
  return false;
301
413
  };
302
414
 
415
+ const DEFAULT_RECOVERY_TIMEOUT_MS$1 = 20000;
416
+ const DEFAULT_LOADING_TIMEOUT_MS$1 = 20000;
303
417
  const WebView = ({
304
418
  webviewUrl: initialWebViewUrl,
305
419
  core,
306
420
  webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
307
421
  disableRecovery: _disableRecovery = false,
308
- recoveryTimeout: _recoveryTimeout = 8000,
309
- loadingTimeout: _loadingTimeout = 10000
422
+ recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS$1,
423
+ loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS$1
310
424
  }) => {
311
425
  const webViewRef = useRef(null);
312
426
  const {
@@ -316,6 +430,15 @@ const WebView = ({
316
430
  const {
317
431
  onMessageHandler
318
432
  } = useMessageTransportWebViewBridge(core, webViewRef);
433
+ const {
434
+ recordEvent,
435
+ getMeta
436
+ } = useWebViewPhaseTimers({
437
+ core,
438
+ loadingTimeout: _loadingTimeout,
439
+ recoveryTimeout: _recoveryTimeout,
440
+ webViewUrl
441
+ });
319
442
  const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
320
443
  /**
321
444
  * Block the message transport when the webview is unmounted
@@ -357,13 +480,23 @@ const WebView = ({
357
480
  return newWebViewUrl;
358
481
  });
359
482
  }, [core]);
360
- const blockAndReloadWebViewOsKill = useCallback(() => blockAndReloadWebView('os_kill'), [blockAndReloadWebView]);
483
+ const blockAndReloadWebViewOsKill = useCallback(() => {
484
+ recordEvent('os_kill');
485
+ blockAndReloadWebView('os_kill');
486
+ }, [blockAndReloadWebView, recordEvent]);
361
487
  useEffect(() => core.messageTransport.recoveryManager.onRecoveryRequested(() => blockAndReloadWebView('recovery')), [core, blockAndReloadWebView]);
362
- const setWebViewLoadError = useCallback(() => {
488
+ const setWebViewLoadError = useCallback(phase => {
363
489
  // Error was already thrown, do not throw again
364
490
  if (core.initialization.error instanceof WebViewFailedToLoadError) return;
365
- core.initialization.error = new WebViewFailedToLoadError();
366
- }, [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]);
367
500
  /**
368
501
  * Reload the webview with a clean state when a timeout is reached
369
502
  * and the webview did not get to the loaded state yet
@@ -371,7 +504,7 @@ const WebView = ({
371
504
  const startRecoveryTimeout = useWebViewRecoveryTimeout({
372
505
  core,
373
506
  disableRecovery: _disableRecovery,
374
- onFailedToLoadAfterClearState: setWebViewLoadError,
507
+ onFailedToLoadAfterClearState: setWebViewLoadErrorWithPhase('after_clear_state'),
375
508
  recoveryTimeout: _recoveryTimeout,
376
509
  setWebViewUrl,
377
510
  webViewUrl
@@ -379,17 +512,30 @@ const WebView = ({
379
512
  const webViewLoadErrorCountRef = useRef(0);
380
513
  const onWebViewLoadError = useCallback(() => {
381
514
  webViewLoadErrorCountRef.current = webViewLoadErrorCountRef.current + 1;
515
+ recordEvent('native_error');
382
516
  /**
383
517
  * This is the first attempt to load the webview, do not throw an error
384
518
  * because the recovery system will attempt to reload the webview
385
519
  */
386
520
  if (webViewLoadErrorCountRef.current === 1) return;
387
- setWebViewLoadError();
388
- }, [setWebViewLoadError]);
521
+ setWebViewLoadError('native_error');
522
+ }, [recordEvent, setWebViewLoadError]);
389
523
  const {
390
- onLoad,
391
- onLoadStart
392
- } = 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]);
393
539
  return /*#__PURE__*/jsx(WebView$1, {
394
540
  ref: webViewRef,
395
541
  source: {
@@ -398,7 +544,7 @@ const WebView = ({
398
544
  containerStyle: containerStyles,
399
545
  style: styles['webview'],
400
546
  onMessage: onMessageHandler,
401
- onLoadEnd: () => startRecoveryTimeout(),
547
+ onLoadEnd: onLoadEnd,
402
548
  onLoadStart: onLoadStart,
403
549
  onLoad: onLoad,
404
550
  hideKeyboardAccessoryView: true,
@@ -450,6 +596,105 @@ const getEmbeddedWebView = () => {
450
596
  }
451
597
  };
452
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;
453
698
  // Wires the JS-side bridge to the native WKWebView singleton owned by Swift.
454
699
  // This is intentionally not a React component: the embedded webview lives
455
700
  // outside the React tree (in a dedicated UIWindow on iOS), so its setup runs
@@ -462,10 +707,47 @@ const getEmbeddedWebView = () => {
462
707
  const setupEmbeddedWebView = ({
463
708
  webviewUrl,
464
709
  core,
465
- webviewDebuggingEnabled: _webviewDebuggingEnabled = false
710
+ webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
711
+ loadingTimeoutMs: _loadingTimeoutMs = DEFAULT_LOADING_TIMEOUT_MS,
712
+ recoveryTimeoutMs: _recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS
466
713
  }) => {
467
714
  const native = getEmbeddedWebView();
468
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
+ };
469
751
  const visibilityChannel = createRequestChannel(core.messageTransport);
470
752
  const removeVisibilityHandler = visibilityChannel.handle('setVisibility', visible => {
471
753
  native.setVisible(visible).catch(err => {
@@ -479,6 +761,15 @@ const setupEmbeddedWebView = ({
479
761
  });
480
762
  };
481
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);
482
773
  const onMessageSub = native.addListener('onMessage', event => {
483
774
  let parsed = null;
484
775
  try {
@@ -501,7 +792,32 @@ const setupEmbeddedWebView = ({
501
792
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
502
793
  });
503
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
+ });
504
819
  const onLoadErrorSub = native.addListener('onLoadError', event => {
820
+ phaseTimers.recordEvent('native_error');
505
821
  logger.warn('EmbeddedWebView load error', event);
506
822
  logger.instrument('Embedded webview load error', {
507
823
  environmentId: core.environmentId,
@@ -515,7 +831,13 @@ const setupEmbeddedWebView = ({
515
831
  time: 0,
516
832
  webviewUrl: builtUrl.toString()
517
833
  });
518
- 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
+ });
519
841
  });
520
842
  native.setDebuggingEnabled(_webviewDebuggingEnabled).catch(err => {
521
843
  logger.warn('EmbeddedWebView.setDebuggingEnabled failed', err);
@@ -524,11 +846,18 @@ const setupEmbeddedWebView = ({
524
846
  logger.warn('EmbeddedWebView.setUrl failed', err);
525
847
  });
526
848
  return () => {
849
+ clearLoadingTimer();
850
+ clearRecoveryTimer();
527
851
  removeVisibilityHandler();
528
852
  core.messageTransport.off(handleHostMessage);
853
+ core.messageTransport.off(handleSdkReady);
529
854
  onMessageSub.remove();
530
855
  onShouldStartLoadSub.remove();
856
+ onLoadStartSub.remove();
857
+ onLoadSub.remove();
858
+ onLoadEndSub.remove();
531
859
  onLoadErrorSub.remove();
860
+ phaseTimers.dispose();
532
861
  };
533
862
  };
534
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.0",
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.0",
22
- "@dynamic-labs/client": "4.83.0",
23
- "@dynamic-labs/logger": "4.83.0",
24
- "@dynamic-labs/message-transport": "4.83.0",
25
- "@dynamic-labs/webview-messages": "4.83.0"
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';