@dynamic-labs/react-native-extension 4.91.4 → 4.91.6

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.
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
@@ -24,7 +24,7 @@ import org.json.JSONObject
24
24
  import java.util.UUID
25
25
 
26
26
  private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
- private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L
28
28
 
29
29
  // Singleton owning the in-app overlay view that hosts the WebView.
30
30
  // Mirrors EmbeddedWebViewController on iOS.
@@ -66,6 +66,12 @@ object EmbeddedWebViewController {
66
66
  // prompting JS again — avoiding a potential approval loop.
67
67
  private var bypassUrl: String? = null
68
68
 
69
+ // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a
70
+ // top-frame navigation whose origin matches, it allows it immediately — no
71
+ // JS round-trip required. Cleared after first use so subsequent navigations
72
+ // still go through the JS allowlist.
73
+ private var preApprovedOrigin: String? = null
74
+
69
75
  private var emitterToken: java.util.UUID? = null
70
76
 
71
77
  // Module-side hook: assign the emitter under a token. The token lets
@@ -108,6 +114,11 @@ object EmbeddedWebViewController {
108
114
  return@runOnMain
109
115
  }
110
116
  val webView = ensureWebView() ?: return@runOnMain
117
+ // Pre-approve the origin so the first `shouldOverrideUrlLoading` call
118
+ // can skip the JS round-trip. On cold boot the JS thread may be too
119
+ // congested to respond within the navigation-decision timeout, silently
120
+ // cancelling the load.
121
+ preApprovedOrigin = originString(android.net.Uri.parse(url))
111
122
  webView.loadUrl(url)
112
123
  }
113
124
  }
@@ -280,6 +291,7 @@ object EmbeddedWebViewController {
280
291
  pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
292
  pendingTimeouts.clear()
282
293
  bypassUrl = null
294
+ preApprovedOrigin = null
283
295
  }
284
296
 
285
297
  private fun runOnMain(action: () -> Unit) {
@@ -332,6 +344,7 @@ object EmbeddedWebViewController {
332
344
  val isAllowedTopFrameScheme =
333
345
  scheme == "https" || (httpAllowed && scheme == "http")
334
346
  if (!isAllowedTopFrameScheme) {
347
+ preApprovedOrigin = null
335
348
  emitLoadError(
336
349
  url = url,
337
350
  code = -1,
@@ -342,6 +355,17 @@ object EmbeddedWebViewController {
342
355
  return true
343
356
  }
344
357
 
358
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
359
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
360
+ // where the JS thread is too congested to respond to
361
+ // `onShouldStartLoad` within the navigation-decision timeout.
362
+ val approved = preApprovedOrigin
363
+ if (approved != null && approved == originString(request.url)) {
364
+ preApprovedOrigin = null
365
+ bypassUrl = url
366
+ return false
367
+ }
368
+
345
369
  val id = UUID.randomUUID().toString()
346
370
  pendingNavigationUrls[id] = url
347
371
 
@@ -486,6 +510,22 @@ object EmbeddedWebViewController {
486
510
  }
487
511
  }
488
512
 
513
+ // Extract the scheme + host + port origin from a URI (e.g.
514
+ // "https://webview.dynamicauth.com"). Returns null for URIs without a host.
515
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
516
+ // "https://example.com" and "https://example.com:443" produce the same
517
+ // origin string.
518
+ private fun originString(uri: android.net.Uri?): String? {
519
+ val scheme = uri?.scheme ?: return null
520
+ val host = uri.host ?: return null
521
+ val port = uri.port
522
+
523
+ val shouldIncludePort = port != -1 &&
524
+ !((scheme == "https" && port == 443) || (scheme == "http" && port == 80))
525
+
526
+ return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host"
527
+ }
528
+
489
529
  // Refuse target=_blank / window.open popups (matches react-native-webview's
490
530
  // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
491
531
  // to open the new window inside the overlay).
package/index.cjs CHANGED
@@ -35,7 +35,7 @@ function _interopNamespace(e) {
35
35
  return Object.freeze(n);
36
36
  }
37
37
 
38
- var version = "4.91.4";
38
+ var version = "4.91.6";
39
39
 
40
40
  function _extends() {
41
41
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -1002,6 +1002,7 @@ const setupEmbeddedWebView = ({
1002
1002
  let recoveryTimer = null;
1003
1003
  let hasFailed = false;
1004
1004
  let hasEmittedSuccessLog = false;
1005
+ let tornDown = false;
1005
1006
  const clearLoadingTimer = () => {
1006
1007
  if (loadingTimer !== null) {
1007
1008
  clearTimeout(loadingTimer);
@@ -1023,6 +1024,7 @@ const setupEmbeddedWebView = ({
1023
1024
  hasFailed = true;
1024
1025
  clearLoadingTimer();
1025
1026
  clearRecoveryTimer();
1027
+ logger.debug(`[EmbeddedWebView] raiseFailure phase=${phase}`);
1026
1028
  const meta = phaseTimers.getMeta({
1027
1029
  phase
1028
1030
  });
@@ -1064,6 +1066,7 @@ const setupEmbeddedWebView = ({
1064
1066
  const handleSdkReady = message => {
1065
1067
  if (message.origin !== 'webview') return;
1066
1068
  if (message.type !== webviewMessages.sdkHasLoadedEventName) return;
1069
+ logger.debug('[EmbeddedWebView] SDK ready signal received');
1067
1070
  clearRecoveryTimer();
1068
1071
  if (hasEmittedSuccessLog || hasFailed) return;
1069
1072
  hasEmittedSuccessLog = true;
@@ -1099,11 +1102,13 @@ const setupEmbeddedWebView = ({
1099
1102
  isTopFrame: event.isTopFrame,
1100
1103
  url: event.url
1101
1104
  }, builtUrl);
1105
+ logger.debug(`[EmbeddedWebView] onShouldStartLoad id=${event.id} url=${event.url} isTopFrame=${event.isTopFrame} allow=${allow}`);
1102
1106
  native.respondToShouldStartLoad(event.id, allow).catch(err => {
1103
1107
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
1104
1108
  });
1105
1109
  });
1106
1110
  const onLoadStartSub = native.addListener('onLoadStart', () => {
1111
+ logger.debug('[EmbeddedWebView] onLoadStart');
1107
1112
  phaseTimers.recordEvent('load_start');
1108
1113
  // Re-arm both timers on every load_start. A retry from the native
1109
1114
  // side (e.g. a redirect, or a reload after a transient failure)
@@ -1116,10 +1121,12 @@ const setupEmbeddedWebView = ({
1116
1121
  }, _loadingTimeoutMs);
1117
1122
  });
1118
1123
  const onLoadSub = native.addListener('onLoad', () => {
1124
+ logger.debug('[EmbeddedWebView] onLoad (response committed)');
1119
1125
  phaseTimers.recordEvent('load');
1120
1126
  clearLoadingTimer();
1121
1127
  });
1122
1128
  const onLoadEndSub = native.addListener('onLoadEnd', () => {
1129
+ logger.debug('[EmbeddedWebView] onLoadEnd (page finished)');
1123
1130
  phaseTimers.recordEvent('load_end');
1124
1131
  clearLoadingTimer();
1125
1132
  clearRecoveryTimer();
@@ -1128,6 +1135,7 @@ const setupEmbeddedWebView = ({
1128
1135
  }, _recoveryTimeoutMs);
1129
1136
  });
1130
1137
  const onLoadErrorSub = native.addListener('onLoadError', event => {
1138
+ logger.debug(`[EmbeddedWebView] onLoadError domain=${event.domain} code=${event.code} url=${event.url}`);
1131
1139
  phaseTimers.recordEvent('native_error');
1132
1140
  logger.warn('EmbeddedWebView load error', event);
1133
1141
  raiseFailure('embedded_native_error', {
@@ -1147,13 +1155,28 @@ const setupEmbeddedWebView = ({
1147
1155
  // without emitting onLoadError — leaving onLoadStart (and therefore the
1148
1156
  // timer) never triggered. The onLoadStart handler clears and re-arms this
1149
1157
  // timer when it fires, so behaviour after onLoadStart is unchanged.
1158
+ logger.debug(`[EmbeddedWebView] arming loading timer (${_loadingTimeoutMs}ms) before setUrl`);
1150
1159
  loadingTimer = setTimeout(() => {
1160
+ logger.debug('[EmbeddedWebView] loading timer fired — raising html_load failure');
1151
1161
  raiseFailure('html_load');
1152
1162
  }, _loadingTimeoutMs);
1153
- native.setUrl(builtUrl.toString()).catch(err => {
1154
- logger.warn('EmbeddedWebView.setUrl failed', err);
1155
- });
1163
+ // Defer setUrl by one event-loop tick so the JS thread has fully settled
1164
+ // after listener registration. On cold boot the main thread is congested
1165
+ // with bundle evaluation — yielding here gives pending microtasks and
1166
+ // native bridge batches a chance to flush before the native side starts
1167
+ // firing navigation events that require a JS round-trip response.
1168
+ const warmupTimer = setTimeout(() => {
1169
+ var _a;
1170
+ if (tornDown) return;
1171
+ logger.debug(`[EmbeddedWebView] calling setUrl: ${builtUrl.toString()}`);
1172
+ (_a = native.setUrl(builtUrl.toString())) === null || _a === void 0 ? void 0 : _a.catch(err => {
1173
+ logger.warn('EmbeddedWebView.setUrl failed', err);
1174
+ });
1175
+ }, 0);
1156
1176
  return () => {
1177
+ logger.debug('[EmbeddedWebView] teardown — removing listeners');
1178
+ tornDown = true;
1179
+ clearTimeout(warmupTimer);
1157
1180
  clearLoadingTimer();
1158
1181
  clearRecoveryTimer();
1159
1182
  removeVisibilityHandler();
package/index.js CHANGED
@@ -13,7 +13,7 @@ import { createURL, getInitialURL, addEventListener, openURL } from 'expo-linkin
13
13
  import { openAuthSessionAsync } from 'expo-web-browser';
14
14
  import { createPasskey, PasskeyStamper } from '@turnkey/react-native-passkey-stamper';
15
15
 
16
- var version = "4.91.4";
16
+ var version = "4.91.6";
17
17
 
18
18
  function _extends() {
19
19
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -980,6 +980,7 @@ const setupEmbeddedWebView = ({
980
980
  let recoveryTimer = null;
981
981
  let hasFailed = false;
982
982
  let hasEmittedSuccessLog = false;
983
+ let tornDown = false;
983
984
  const clearLoadingTimer = () => {
984
985
  if (loadingTimer !== null) {
985
986
  clearTimeout(loadingTimer);
@@ -1001,6 +1002,7 @@ const setupEmbeddedWebView = ({
1001
1002
  hasFailed = true;
1002
1003
  clearLoadingTimer();
1003
1004
  clearRecoveryTimer();
1005
+ logger.debug(`[EmbeddedWebView] raiseFailure phase=${phase}`);
1004
1006
  const meta = phaseTimers.getMeta({
1005
1007
  phase
1006
1008
  });
@@ -1042,6 +1044,7 @@ const setupEmbeddedWebView = ({
1042
1044
  const handleSdkReady = message => {
1043
1045
  if (message.origin !== 'webview') return;
1044
1046
  if (message.type !== sdkHasLoadedEventName) return;
1047
+ logger.debug('[EmbeddedWebView] SDK ready signal received');
1045
1048
  clearRecoveryTimer();
1046
1049
  if (hasEmittedSuccessLog || hasFailed) return;
1047
1050
  hasEmittedSuccessLog = true;
@@ -1077,11 +1080,13 @@ const setupEmbeddedWebView = ({
1077
1080
  isTopFrame: event.isTopFrame,
1078
1081
  url: event.url
1079
1082
  }, builtUrl);
1083
+ logger.debug(`[EmbeddedWebView] onShouldStartLoad id=${event.id} url=${event.url} isTopFrame=${event.isTopFrame} allow=${allow}`);
1080
1084
  native.respondToShouldStartLoad(event.id, allow).catch(err => {
1081
1085
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
1082
1086
  });
1083
1087
  });
1084
1088
  const onLoadStartSub = native.addListener('onLoadStart', () => {
1089
+ logger.debug('[EmbeddedWebView] onLoadStart');
1085
1090
  phaseTimers.recordEvent('load_start');
1086
1091
  // Re-arm both timers on every load_start. A retry from the native
1087
1092
  // side (e.g. a redirect, or a reload after a transient failure)
@@ -1094,10 +1099,12 @@ const setupEmbeddedWebView = ({
1094
1099
  }, _loadingTimeoutMs);
1095
1100
  });
1096
1101
  const onLoadSub = native.addListener('onLoad', () => {
1102
+ logger.debug('[EmbeddedWebView] onLoad (response committed)');
1097
1103
  phaseTimers.recordEvent('load');
1098
1104
  clearLoadingTimer();
1099
1105
  });
1100
1106
  const onLoadEndSub = native.addListener('onLoadEnd', () => {
1107
+ logger.debug('[EmbeddedWebView] onLoadEnd (page finished)');
1101
1108
  phaseTimers.recordEvent('load_end');
1102
1109
  clearLoadingTimer();
1103
1110
  clearRecoveryTimer();
@@ -1106,6 +1113,7 @@ const setupEmbeddedWebView = ({
1106
1113
  }, _recoveryTimeoutMs);
1107
1114
  });
1108
1115
  const onLoadErrorSub = native.addListener('onLoadError', event => {
1116
+ logger.debug(`[EmbeddedWebView] onLoadError domain=${event.domain} code=${event.code} url=${event.url}`);
1109
1117
  phaseTimers.recordEvent('native_error');
1110
1118
  logger.warn('EmbeddedWebView load error', event);
1111
1119
  raiseFailure('embedded_native_error', {
@@ -1125,13 +1133,28 @@ const setupEmbeddedWebView = ({
1125
1133
  // without emitting onLoadError — leaving onLoadStart (and therefore the
1126
1134
  // timer) never triggered. The onLoadStart handler clears and re-arms this
1127
1135
  // timer when it fires, so behaviour after onLoadStart is unchanged.
1136
+ logger.debug(`[EmbeddedWebView] arming loading timer (${_loadingTimeoutMs}ms) before setUrl`);
1128
1137
  loadingTimer = setTimeout(() => {
1138
+ logger.debug('[EmbeddedWebView] loading timer fired — raising html_load failure');
1129
1139
  raiseFailure('html_load');
1130
1140
  }, _loadingTimeoutMs);
1131
- native.setUrl(builtUrl.toString()).catch(err => {
1132
- logger.warn('EmbeddedWebView.setUrl failed', err);
1133
- });
1141
+ // Defer setUrl by one event-loop tick so the JS thread has fully settled
1142
+ // after listener registration. On cold boot the main thread is congested
1143
+ // with bundle evaluation — yielding here gives pending microtasks and
1144
+ // native bridge batches a chance to flush before the native side starts
1145
+ // firing navigation events that require a JS round-trip response.
1146
+ const warmupTimer = setTimeout(() => {
1147
+ var _a;
1148
+ if (tornDown) return;
1149
+ logger.debug(`[EmbeddedWebView] calling setUrl: ${builtUrl.toString()}`);
1150
+ (_a = native.setUrl(builtUrl.toString())) === null || _a === void 0 ? void 0 : _a.catch(err => {
1151
+ logger.warn('EmbeddedWebView.setUrl failed', err);
1152
+ });
1153
+ }, 0);
1134
1154
  return () => {
1155
+ logger.debug('[EmbeddedWebView] teardown — removing listeners');
1156
+ tornDown = true;
1157
+ clearTimeout(warmupTimer);
1135
1158
  clearLoadingTimer();
1136
1159
  clearRecoveryTimer();
1137
1160
  removeVisibilityHandler();
@@ -2,7 +2,7 @@ import UIKit
2
2
  import WebKit
3
3
 
4
4
  private let scriptHandlerName = "dynamicEmbeddedWebView"
5
- private let navigationDecisionTimeout: TimeInterval = 2.0
5
+ private let navigationDecisionTimeout: TimeInterval = 5.0
6
6
 
7
7
  // Cleartext http is only permitted in debug builds (e.g. Metro at
8
8
  // http://localhost:4202). Release builds reject http top-frame and sub-frame
@@ -28,6 +28,11 @@ public final class EmbeddedWebViewController: NSObject {
28
28
  private var pendingNavigationDecisions: [String: (WKNavigationActionPolicy) -> Void] = [:]
29
29
  private var navigationTimers: [String: Timer] = [:]
30
30
  private var emitterToken: UUID?
31
+ // Origin pre-approved by `setUrl`. When `decidePolicyFor` sees a top-frame
32
+ // navigation whose origin matches, it allows it immediately — no JS
33
+ // round-trip required. Cleared after first use so subsequent navigations
34
+ // still go through the JS allowlist.
35
+ private var preApprovedOrigin: String?
31
36
 
32
37
  private override init() {
33
38
  super.init()
@@ -65,6 +70,11 @@ public final class EmbeddedWebViewController: NSObject {
65
70
  }
66
71
  ensureWebView()
67
72
  hasLoaded = true
73
+ // Pre-approve the origin so the first `decidePolicyFor` call can skip
74
+ // the JS round-trip. On cold boot the JS thread may be too congested to
75
+ // respond within the 2 s navigation-decision timeout, silently
76
+ // cancelling the load.
77
+ preApprovedOrigin = originString(for: parsed)
68
78
  webView?.load(URLRequest(url: parsed))
69
79
  }
70
80
 
@@ -124,6 +134,7 @@ public final class EmbeddedWebViewController: NSObject {
124
134
  navigationTimers.values.forEach { $0.invalidate() }
125
135
  navigationTimers.removeAll()
126
136
  hasLoaded = false
137
+ preApprovedOrigin = nil
127
138
  }
128
139
 
129
140
  public func postMessage(_ message: String) {
@@ -248,6 +259,24 @@ public final class EmbeddedWebViewController: NSObject {
248
259
  return nil
249
260
  }
250
261
 
262
+ // Extract the scheme + host + port origin from a URL (e.g.
263
+ // "https://webview.dynamicauth.com"). Returns nil for URLs without a host.
264
+ // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
265
+ // "https://example.com" and "https://example.com:443" produce the same
266
+ // origin string.
267
+ private func originString(for url: URL?) -> String? {
268
+ guard let url = url, let scheme = url.scheme, let host = url.host else {
269
+ return nil
270
+ }
271
+ if let port = url.port {
272
+ let isDefaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
273
+ if !isDefaultPort {
274
+ return "\(scheme)://\(host):\(port)"
275
+ }
276
+ }
277
+ return "\(scheme)://\(host)"
278
+ }
279
+
251
280
  // Use JSONSerialization to safely encode an arbitrary string as a JS string literal.
252
281
  private func jsStringLiteral(_ raw: String) -> String {
253
282
  guard
@@ -317,6 +346,17 @@ extension EmbeddedWebViewController: WKNavigationDelegate {
317
346
  return
318
347
  }
319
348
 
349
+ // Fast-path: if the URL's origin matches the pre-approved origin (set
350
+ // by `setUrl`), allow immediately. This eliminates the cold-boot race
351
+ // where the JS thread is too congested to respond to
352
+ // `onShouldStartLoad` within the 2 s navigation-decision timeout.
353
+ if let approved = preApprovedOrigin,
354
+ approved == originString(for: navigationAction.request.url) {
355
+ preApprovedOrigin = nil
356
+ decisionHandler(.allow)
357
+ return
358
+ }
359
+
320
360
  let id = UUID().uuidString
321
361
  pendingNavigationDecisions[id] = decisionHandler
322
362
 
@@ -326,6 +366,17 @@ extension EmbeddedWebViewController: WKNavigationDelegate {
326
366
  if let handler = self.pendingNavigationDecisions.removeValue(forKey: id) {
327
367
  self.navigationTimers.removeValue(forKey: id)?.invalidate()
328
368
  handler(.cancel)
369
+ // Surface an explicit load error so the SDK's error path runs
370
+ // immediately instead of waiting for the 20 s html_load timer.
371
+ // Matches Android's EmbeddedWebViewNavigationTimeout behavior.
372
+ let timeoutMs = Int(navigationDecisionTimeout * 1000)
373
+ self.eventEmitter?("onLoadError", [
374
+ "url": url,
375
+ "code": -1,
376
+ "domain": "EmbeddedWebViewNavigationTimeout",
377
+ "description": "Navigation decision timed out after \(timeoutMs)ms",
378
+ "isProvisional": true,
379
+ ])
329
380
  }
330
381
  }
331
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamic-labs/react-native-extension",
3
- "version": "4.91.4",
3
+ "version": "4.91.6",
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.91.4",
22
- "@dynamic-labs/client": "4.91.4",
23
- "@dynamic-labs/logger": "4.91.4",
24
- "@dynamic-labs/message-transport": "4.91.4",
25
- "@dynamic-labs/webview-messages": "4.91.4"
21
+ "@dynamic-labs/assert-package-version": "4.91.6",
22
+ "@dynamic-labs/client": "4.91.6",
23
+ "@dynamic-labs/logger": "4.91.6",
24
+ "@dynamic-labs/message-transport": "4.91.6",
25
+ "@dynamic-labs/webview-messages": "4.91.6"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=18.0.0 <20.0.0",