@dynamic-labs/react-native-extension 4.92.3 → 4.93.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 (40) hide show
  1. package/android/EmbeddedWebViewController.kt +19 -2
  2. package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  3. package/android/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  4. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  5. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  6. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  7. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +19 -2
  8. package/index.cjs +252 -796
  9. package/index.js +251 -797
  10. package/ios/EmbeddedWebViewController.swift +7 -6
  11. package/package.json +6 -6
  12. package/src/ReactNativeExtension/ReactNativeExtension.d.ts +11 -2
  13. package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +6 -19
  14. package/src/components/WebView/WebView.d.ts +17 -6
  15. package/src/errors/WebViewFailedToLoadError.d.ts +1 -86
  16. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/embeddedWebViewPhaseTimers.d.ts +0 -66
  17. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/index.d.ts +0 -1
  18. package/src/components/WebView/constants.d.ts +0 -3
  19. package/src/components/WebView/successLog/emitSuccessLog.d.ts +0 -35
  20. package/src/components/WebView/successLog/index.d.ts +0 -3
  21. package/src/components/WebView/useWebViewLoadingTimeout/index.d.ts +0 -1
  22. package/src/components/WebView/useWebViewLoadingTimeout/useWebViewLoadingTimeout.d.ts +0 -4
  23. package/src/components/WebView/useWebViewPhaseTimers/index.d.ts +0 -1
  24. package/src/components/WebView/useWebViewPhaseTimers/useWebViewPhaseTimers.d.ts +0 -46
  25. package/src/components/WebView/useWebViewRecoveryTimeout/index.d.ts +0 -1
  26. package/src/components/WebView/useWebViewRecoveryTimeout/useWebViewRecoveryTimeout.d.ts +0 -12
  27. package/src/components/WebView/useWebViewSuccessLog/index.d.ts +0 -1
  28. package/src/components/WebView/useWebViewSuccessLog/useWebViewSuccessLog.d.ts +0 -28
  29. package/src/components/WebView/utils/assignEnvironmentIdToUrl/assignEnvironmentIdToUrl.d.ts +0 -1
  30. package/src/components/WebView/utils/assignEnvironmentIdToUrl/index.d.ts +0 -1
  31. package/src/components/WebView/utils/assignStartTimeToUrl/assignStartTimeToUrl.d.ts +0 -1
  32. package/src/components/WebView/utils/assignStartTimeToUrl/index.d.ts +0 -1
  33. package/src/components/WebView/utils/hasClearStateInUrl/hasClearStateInUrl.d.ts +0 -1
  34. package/src/components/WebView/utils/hasClearStateInUrl/index.d.ts +0 -1
  35. package/src/components/WebView/utils/increaseRetryToUrl/increaseRetryToUrl.d.ts +0 -1
  36. package/src/components/WebView/utils/increaseRetryToUrl/index.d.ts +0 -1
  37. package/src/components/WebView/utils/setClearStateToUrl/index.d.ts +0 -1
  38. package/src/components/WebView/utils/setClearStateToUrl/setClearStateToUrl.d.ts +0 -1
  39. package/src/components/WebView/webViewPhaseTimerCore/index.d.ts +0 -2
  40. package/src/components/WebView/webViewPhaseTimerCore/webViewPhaseTimerCore.d.ts +0 -123
package/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { assertPackageVersion } from '@dynamic-labs/assert-package-version';
2
2
  import { StyleSheet, Platform } from 'react-native';
3
- import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
4
  import { WebView as WebView$1 } from 'react-native-webview';
5
+ import { createWebViewLoadController, EMBEDDED_WEBVIEW_HTTP_ERROR_DOMAIN } from '@dynamic-labs/client';
6
+ export { WebViewFailedToLoadError } from '@dynamic-labs/client';
5
7
  import { Logger } from '@dynamic-labs/logger';
6
8
  import { messageTransportDataJsonReviver, parseMessageTransportData, messageTransportDataJsonReplacer, createRequestChannel } from '@dynamic-labs/message-transport';
7
- import { sdkHasLoadedEventName } from '@dynamic-labs/webview-messages';
8
9
  import { setItemAsync, getItemAsync, deleteItemAsync } from 'expo-secure-store';
9
10
  import { jsx } from 'react/jsx-runtime';
10
11
  import { requireOptionalNativeModule, requireNativeModule } from 'expo-modules-core';
@@ -13,7 +14,7 @@ import { createURL, getInitialURL, addEventListener, openURL } from 'expo-linkin
13
14
  import { openAuthSessionAsync } from 'expo-web-browser';
14
15
  import { createPasskey, PasskeyStamper } from '@turnkey/react-native-passkey-stamper';
15
16
 
16
- var version = "4.92.3";
17
+ var version = "4.93.0";
17
18
 
18
19
  function _extends() {
19
20
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -25,12 +26,35 @@ function _extends() {
25
26
  }, _extends.apply(null, arguments);
26
27
  }
27
28
 
28
- class WebViewFailedToLoadError extends Error {
29
- constructor(meta) {
30
- super('Could not load Dynamic WebView');
31
- this.meta = meta;
32
- }
33
- }
29
+ /******************************************************************************
30
+ Copyright (c) Microsoft Corporation.
31
+
32
+ Permission to use, copy, modify, and/or distribute this software for any
33
+ purpose with or without fee is hereby granted.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
36
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
37
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
38
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
39
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
40
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
41
+ PERFORMANCE OF THIS SOFTWARE.
42
+ ***************************************************************************** */
43
+
44
+ function __awaiter(thisArg, _arguments, P, generator) {
45
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
46
+ return new (P || (P = Promise))(function (resolve, reject) {
47
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
48
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
49
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
50
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
51
+ });
52
+ }
53
+
54
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
55
+ var e = new Error(message);
56
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
57
+ };
34
58
 
35
59
  const logger = new Logger('react-native-extension');
36
60
 
@@ -101,284 +125,40 @@ const useMessageTransportWebViewBridge = (core, webViewRef) => {
101
125
  };
102
126
  };
103
127
 
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
- /**
111
- * Wire-format `type` of a webview-side `manifest` request.
112
- *
113
- * `requestChannel.request('manifest')` sends a `MessageTransportData` whose
114
- * `type` is the literal request name. The host replies with `manifest__ack`,
115
- * which we ignore: receiving the request is sufficient evidence that the
116
- * webview JS bundle is alive and the message bridge works.
117
- */
118
- const MANIFEST_MESSAGE_TYPE = 'manifest';
119
- const emptyTimings = () => ({
120
- htmlLoadStartedAt: null,
121
- htmlLoadedAt: null,
122
- manifestReceivedAt: null,
123
- onLoadEndAt: null,
124
- sdkReadyAt: null
125
- });
126
- const diffMs = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
127
- /**
128
- * Derive cross-phase durations from raw timestamps. The four durations
129
- * are what end up in the failure / success log meta:
130
- *
131
- * - `htmlLoadMs` — `onLoadStart` to `onLoad`; native HTML fetch
132
- * - `onLoadToOnLoadEndMs` — `onLoad` to `onLoadEnd`; native render/parse
133
- * - `manifestReceivedMs` — `onLoadEnd` to first `manifest` request from
134
- * the webview JS; "JS bundle is alive" signal
135
- * - `sdkReadyMs` — `onLoadEnd` to first `sdkHasLoaded` event from the
136
- * webview JS; "SDK fully bootstrapped" signal
137
- */
138
- const computePhaseDurations = timings => ({
139
- htmlLoadMs: diffMs(timings.htmlLoadedAt, timings.htmlLoadStartedAt),
140
- manifestReceivedMs: diffMs(timings.manifestReceivedAt, timings.onLoadEndAt),
141
- onLoadToOnLoadEndMs: diffMs(timings.onLoadEndAt, timings.htmlLoadedAt),
142
- sdkReadyMs: diffMs(timings.sdkReadyAt, timings.onLoadEndAt)
143
- });
144
- /**
145
- * Build the success-shape meta from a {@link WebViewPhaseTimerCore}
146
- * snapshot plus the path-specific fields. Both call sites call this
147
- * with their own context (RN reads `hadClearState` / `retryCount` from
148
- * the URL; embedded passes constants) so the success-log payload stays
149
- * identical across the two paths.
150
- */
151
- const buildSuccessMeta = ({
152
- core,
153
- hadClearState,
154
- loadingTimeoutMs,
155
- recoveryTimeoutMs,
156
- retryCount,
157
- webviewUrl
158
- }) => Object.assign(Object.assign({
159
- hadClearState,
160
- loadingTimeoutMs,
161
- recoveryTimeoutMs,
162
- retryCount,
163
- webviewUrl
164
- }, computePhaseDurations(core.getTimings())), core.getCounters());
165
- /**
166
- * Shared state machine driving WebView load-phase instrumentation.
167
- *
168
- * Both the React `<WebView>` (RN bridge) and the native embedded WebView
169
- * (native bridge) feed lifecycle events into this core and produce
170
- * matching meta from the same timings + counters. Each path layers its
171
- * own path-specific fields (`hadClearState`, `retryCount`, etc.) on top
172
- * of the raw state this core exposes via `getTimings()` / `getCounters()`.
173
- *
174
- * The core owns the `core.messageTransport` subscription that captures
175
- * the two webview-originated signals bracketing SDK bootstrap
176
- * (`manifest` once the JS bundle is alive; `sdkHasLoadedEventName`
177
- * once the SDK is fully ready). Native bridge lifecycle events come in
178
- * via `recordEvent` instead.
179
- */
180
- const createWebViewPhaseTimerCore = ({
181
- core
182
- }) => {
183
- let timings = emptyTimings();
184
- let nativeErrorCount = 0;
185
- let osKillCount = 0;
186
- /**
187
- * We only capture the first occurrence per attempt so that a healthy
188
- * reload doesn't overwrite the timing from the attempt that ultimately
189
- * failed.
190
- */
191
- const handleMessage = message => {
192
- if (message.origin !== 'webview') return;
193
- if (message.type === MANIFEST_MESSAGE_TYPE && timings.manifestReceivedAt === null) {
194
- timings.manifestReceivedAt = Date.now();
195
- }
196
- if (message.type === sdkHasLoadedEventName && timings.sdkReadyAt === null) {
197
- timings.sdkReadyAt = Date.now();
198
- }
199
- };
200
- core.messageTransport.on(handleMessage);
201
- return {
202
- dispose: () => {
203
- core.messageTransport.off(handleMessage);
204
- },
205
- getCounters: () => ({
206
- nativeErrorCount,
207
- osKillCount
208
- }),
209
- getTimings: () => timings,
210
- recordEvent: event => {
211
- const now = Date.now();
212
- switch (event) {
213
- case 'load_start':
214
- timings = emptyTimings();
215
- timings.htmlLoadStartedAt = now;
216
- break;
217
- case 'load':
218
- timings.htmlLoadedAt = now;
219
- break;
220
- case 'load_end':
221
- timings.onLoadEndAt = now;
222
- break;
223
- case 'native_error':
224
- nativeErrorCount += 1;
225
- break;
226
- case 'os_kill':
227
- osKillCount += 1;
228
- break;
229
- }
230
- }
231
- };
232
- };
233
-
234
- const RETRY_QUERY_PARAM = 'retry';
235
- const readRetryCount = webViewUrl => {
236
- const raw = webViewUrl.searchParams.get(RETRY_QUERY_PARAM);
237
- if (raw === null) return 0;
238
- const parsed = Number(raw);
239
- return Number.isFinite(parsed) ? parsed : 0;
240
- };
241
- /**
242
- * Snapshot returned by the phase-timer core when it hasn't been
243
- * instantiated yet (briefly true between hook construction and the
244
- * `useEffect` running). Lets `getSuccessMeta` / `getMeta` produce a
245
- * well-formed empty meta in that window without special-casing each
246
- * field.
247
- */
248
- const EMPTY_CORE_SNAPSHOT = {
249
- getCounters: () => ({
250
- nativeErrorCount: 0,
251
- osKillCount: 0
252
- }),
253
- getTimings: () => ({
254
- htmlLoadStartedAt: null,
255
- htmlLoadedAt: null,
256
- manifestReceivedAt: null,
257
- onLoadEndAt: null,
258
- sdkReadyAt: null
259
- })
260
- };
261
- /**
262
- * Tracks how long each step of the WebView load took, so when we raise
263
- * `WebViewFailedToLoadError` we can attach the timings to the error log.
264
- *
265
- * The timings cover the host-side phases of the load — the webview-side
266
- * timings (`webview.time_to_load_manifest`, `webview.time_to_sdk_ready`)
267
- * still come from the webview itself once it boots, but those don't help
268
- * when the webview never boots in the first place.
269
- *
270
- * The actual state machine lives in {@link createWebViewPhaseTimerCore},
271
- * which is also reused by the embedded native WebView path. This hook
272
- * only layers the React-specific bits (ref-based core lifetime tied to
273
- * `useEffect`, URL-derived `hadClearState` / `retryCount`).
274
- */
275
- const useWebViewPhaseTimers = ({
276
- core,
277
- loadingTimeout,
278
- recoveryTimeout,
279
- webViewUrl
280
- }) => {
281
- const phaseCoreRef = useRef(null);
282
- const webViewUrlRef = useRef(webViewUrl);
283
- webViewUrlRef.current = webViewUrl;
284
- /**
285
- * Owning the message-transport subscription in `useEffect` matches the
286
- * lifetime semantics any other React subscription would have, and lets
287
- * us cleanly re-subscribe if `core` is ever swapped at runtime.
288
- */
289
- useEffect(() => {
290
- const phaseCore = createWebViewPhaseTimerCore({
291
- core
292
- });
293
- phaseCoreRef.current = phaseCore;
294
- return () => {
295
- phaseCore.dispose();
296
- if (phaseCoreRef.current === phaseCore) {
297
- phaseCoreRef.current = null;
298
- }
299
- };
300
- }, [core]);
301
- const recordEvent = useCallback(event => {
302
- var _a;
303
- (_a = phaseCoreRef.current) === null || _a === void 0 ? void 0 : _a.recordEvent(event);
304
- }, []);
305
- const getSuccessMeta = useCallback(() => {
306
- var _a;
307
- const url = webViewUrlRef.current;
308
- return buildSuccessMeta({
309
- core: (_a = phaseCoreRef.current) !== null && _a !== void 0 ? _a : EMPTY_CORE_SNAPSHOT,
310
- hadClearState: hasClearStateInUrl(url),
311
- loadingTimeoutMs: loadingTimeout,
312
- recoveryTimeoutMs: recoveryTimeout,
313
- retryCount: readRetryCount(url),
314
- webviewUrl: url.toString()
315
- });
316
- }, [loadingTimeout, recoveryTimeout]);
317
- const getMeta = useCallback(({
318
- phase
319
- }) => Object.assign(Object.assign({}, getSuccessMeta()), {
320
- phase
321
- }), [getSuccessMeta]);
128
+ const useWebViewVisibility = core => {
129
+ const webViewVisibilityRequestChannelRef = useRef(createRequestChannel(core.messageTransport));
130
+ const [visible, setVisible] = useState(false);
131
+ useEffect(() => webViewVisibilityRequestChannelRef.current.handle('setVisibility', setVisible), [setVisible]);
322
132
  return {
323
- getMeta,
324
- getSuccessMeta,
325
- recordEvent
133
+ visible
326
134
  };
327
135
  };
328
136
 
329
- /******************************************************************************
330
- Copyright (c) Microsoft Corporation.
331
-
332
- Permission to use, copy, modify, and/or distribute this software for any
333
- purpose with or without fee is hereby granted.
334
-
335
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
336
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
337
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
338
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
339
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
340
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
341
- PERFORMANCE OF THIS SOFTWARE.
342
- ***************************************************************************** */
343
-
344
- function __awaiter(thisArg, _arguments, P, generator) {
345
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
346
- return new (P || (P = Promise))(function (resolve, reject) {
347
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
348
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
349
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
350
- step((generator = generator.apply(thisArg, _arguments || [])).next());
351
- });
352
- }
353
-
354
- typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
355
- var e = new Error(message);
356
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
357
- };
358
-
359
- const SUCCESS_LOG_KEY = 'webview.load_succeeded';
360
- /**
361
- * Shared emission pattern for the `webview.load_succeeded`
362
- * instrumentation log. Both the RN `<WebView>` and the embedded
363
- * WebView fire the same event with the same payload shape after
364
- * checking the same cooldown gate — this helper centralises that
365
- * sequence so the two call sites stay aligned and any future field we
366
- * add to the success log lands in both places automatically.
367
- */
368
- const emitSuccessLog = _a => __awaiter(void 0, [_a], void 0, function* ({
369
- cooldown,
370
- core,
371
- getMeta,
372
- name
373
- }) {
374
- if (!(yield cooldown.shouldEmit())) return;
375
- logger.instrument(`${name} loaded successfully`, Object.assign({
376
- environmentId: core.environmentId,
377
- hostSdkSessionId: core.hostSdkSessionId,
378
- key: SUCCESS_LOG_KEY,
379
- time: 0
380
- }, getMeta()));
381
- yield cooldown.recordEmitted();
137
+ const styles = StyleSheet.create({
138
+ container: {
139
+ backgroundColor: 'transparent',
140
+ bottom: 0,
141
+ flex: 1,
142
+ left: 0,
143
+ position: 'absolute',
144
+ right: 0,
145
+ top: 0,
146
+ width: '100%'
147
+ },
148
+ hide: {
149
+ elevation: 0,
150
+ opacity: 0,
151
+ zIndex: -10000
152
+ },
153
+ show: {
154
+ elevation: 10000,
155
+ opacity: 1,
156
+ zIndex: 10000
157
+ },
158
+ webview: {
159
+ backgroundColor: 'transparent',
160
+ flex: 1
161
+ }
382
162
  });
383
163
 
384
164
  const SUCCESS_LOG_COOLDOWN_MS = 60 * 60 * 1000;
@@ -433,225 +213,6 @@ const createSuccessLogCooldown = ({
433
213
  })
434
214
  });
435
215
 
436
- const SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.webview.successLogLastEmittedAt';
437
- /**
438
- * Emit a `webview.load_succeeded` instrumentation log the first time
439
- * the SDK signals it's ready, gated by a device-local 1h cooldown so a
440
- * customer with many WebView mounts (or many app launches) doesn't spam
441
- * the logs pipeline.
442
- *
443
- * The error path is unaffected: failures keep going through
444
- * `logger.error(WebViewFailedToLoadError, meta)` with no cooldown.
445
- *
446
- * The hook intentionally fires only once per mount (`hasEmittedRef`):
447
- * after a successful boot we don't expect another `sdkHasLoaded` for
448
- * the same WebView lifetime, and emitting twice for the same load would
449
- * be misleading.
450
- *
451
- * Cooldown gating + log emission are delegated to the shared
452
- * `successLog` helpers so this hook and the embedded native WebView
453
- * path stay in lockstep on payload shape and storage semantics; the
454
- * dedicated storage key here keeps the RN-path cooldown independent
455
- * from the embedded path's.
456
- */
457
- const useWebViewSuccessLog = ({
458
- core,
459
- getSuccessMeta
460
- }) => {
461
- const hasEmittedRef = useRef(false);
462
- const getSuccessMetaRef = useRef(getSuccessMeta);
463
- getSuccessMetaRef.current = getSuccessMeta;
464
- useEffect(() => {
465
- const cooldown = createSuccessLogCooldown({
466
- name: 'webview',
467
- storageKey: SUCCESS_LOG_COOLDOWN_STORAGE_KEY
468
- });
469
- const handler = message => __awaiter(void 0, void 0, void 0, function* () {
470
- if (message.origin !== 'webview') return;
471
- if (message.type !== sdkHasLoadedEventName) return;
472
- if (hasEmittedRef.current) return;
473
- hasEmittedRef.current = true;
474
- try {
475
- yield emitSuccessLog({
476
- cooldown,
477
- core,
478
- getMeta: getSuccessMetaRef.current,
479
- name: 'Webview'
480
- });
481
- } catch (err) {
482
- logger.warn('useWebViewSuccessLog.emitSuccessLog failed', {
483
- error: String(err)
484
- });
485
- }
486
- });
487
- core.messageTransport.on(handler);
488
- return () => {
489
- core.messageTransport.off(handler);
490
- };
491
- }, [core]);
492
- };
493
-
494
- const useWebViewVisibility = core => {
495
- const webViewVisibilityRequestChannelRef = useRef(createRequestChannel(core.messageTransport));
496
- const [visible, setVisible] = useState(false);
497
- useEffect(() => webViewVisibilityRequestChannelRef.current.handle('setVisibility', setVisible), [setVisible]);
498
- return {
499
- visible
500
- };
501
- };
502
-
503
- const styles = StyleSheet.create({
504
- container: {
505
- backgroundColor: 'transparent',
506
- bottom: 0,
507
- flex: 1,
508
- left: 0,
509
- position: 'absolute',
510
- right: 0,
511
- top: 0,
512
- width: '100%'
513
- },
514
- hide: {
515
- elevation: 0,
516
- opacity: 0,
517
- zIndex: -10000
518
- },
519
- show: {
520
- elevation: 10000,
521
- opacity: 1,
522
- zIndex: 10000
523
- },
524
- webview: {
525
- backgroundColor: 'transparent',
526
- flex: 1
527
- }
528
- });
529
-
530
- const setClearStateToUrl = webViewUrl => {
531
- const newWebViewUrl = new URL(webViewUrl);
532
- newWebViewUrl.searchParams.set(CLEAR_STATE_PARAM, 'true');
533
- newWebViewUrl.searchParams.delete('retry');
534
- return newWebViewUrl;
535
- };
536
-
537
- const useWebViewRecoveryTimeout = ({
538
- core,
539
- webViewUrl,
540
- disableRecovery,
541
- recoveryTimeout,
542
- setWebViewUrl,
543
- onFailedToLoadAfterClearState: _onFailedToLoadAfterClearState = () => {}
544
- }) => {
545
- const timeoutRef = useRef(null);
546
- const onFailedToLoadAfterClearStateRef = useRef(_onFailedToLoadAfterClearState);
547
- onFailedToLoadAfterClearStateRef.current = _onFailedToLoadAfterClearState;
548
- /**
549
- * Clear timeout on unmount
550
- */
551
- useEffect(() => () => {
552
- if (timeoutRef.current) {
553
- clearTimeout(timeoutRef.current);
554
- timeoutRef.current = null;
555
- }
556
- }, []);
557
- return useCallback(() => {
558
- if (disableRecovery) {
559
- logger.debug('timeout recovery: is disabled');
560
- return;
561
- }
562
- if (hasClearStateInUrl(webViewUrl)) {
563
- logger.instrument('Webview failed to load after clearing its state', {
564
- environmentId: core.environmentId,
565
- hostSdkSessionId: core.hostSdkSessionId,
566
- key: 'webview.failed_to_load_after_clear_state',
567
- time: 0,
568
- webviewUrl: webViewUrl.toString()
569
- });
570
- onFailedToLoadAfterClearStateRef.current();
571
- return;
572
- }
573
- logger.debug('timeout recovery: registering timeout', webViewUrl.toString());
574
- if (timeoutRef.current) {
575
- clearTimeout(timeoutRef.current);
576
- }
577
- timeoutRef.current = setTimeout(() => {
578
- if (!core.messageTransport.isBlocked()) {
579
- logger.instrument('Webview recovery skipped because the webview did load', {
580
- environmentId: core.environmentId,
581
- hostSdkSessionId: core.hostSdkSessionId,
582
- key: 'webview.recovery_skipped',
583
- time: 0,
584
- webviewUrl: webViewUrl.toString()
585
- });
586
- return;
587
- }
588
- core.messageTransport.block();
589
- // Increase the retry
590
- setWebViewUrl(webViewUrl => {
591
- const newWebViewUrl = setClearStateToUrl(webViewUrl);
592
- logger.instrument('timeout recovery: reloading webview to clear its state', {
593
- environmentId: core.environmentId,
594
- hostSdkSessionId: core.hostSdkSessionId,
595
- key: 'webview.recovery_by_clear_state',
596
- time: 0,
597
- webviewUrl: webViewUrl.toString()
598
- });
599
- return newWebViewUrl;
600
- });
601
- }, recoveryTimeout);
602
- }, [core, disableRecovery, recoveryTimeout, setWebViewUrl, webViewUrl]);
603
- };
604
-
605
- const increaseRetryToUrl = webViewUrl => {
606
- var _a;
607
- const currentRetry = Number((_a = webViewUrl.searchParams.get('retry')) !== null && _a !== void 0 ? _a : '0');
608
- const newWebViewUrl = new URL(webViewUrl);
609
- newWebViewUrl.searchParams.delete(CLEAR_STATE_PARAM);
610
- newWebViewUrl.searchParams.set('retry', String(currentRetry + 1));
611
- return newWebViewUrl;
612
- };
613
-
614
- const useWebViewLoadingTimeout = (timeout, onTimeout) => {
615
- const timeoutRef = useRef(null);
616
- const onTimeoutRef = useRef(onTimeout);
617
- onTimeoutRef.current = onTimeout;
618
- const onLoadStart = useCallback(() => {
619
- if (timeoutRef.current) {
620
- clearTimeout(timeoutRef.current);
621
- }
622
- timeoutRef.current = setTimeout(() => {
623
- onTimeoutRef.current();
624
- }, timeout);
625
- }, [timeout]);
626
- const onLoad = useCallback(() => {
627
- if (timeoutRef.current) {
628
- clearTimeout(timeoutRef.current);
629
- timeoutRef.current = null;
630
- }
631
- }, []);
632
- useEffect(() => () => {
633
- if (timeoutRef.current) {
634
- clearTimeout(timeoutRef.current);
635
- timeoutRef.current = null;
636
- }
637
- }, []);
638
- return {
639
- onLoad,
640
- onLoadStart
641
- };
642
- };
643
-
644
- const assignEnvironmentIdToUrl = (url, environmentId) => {
645
- const _url = new URL(url);
646
- _url.searchParams.set(ENVIRONMENT_ID_PARAM, environmentId);
647
- return _url;
648
- };
649
-
650
- const assignStartTimeToUrl = url => {
651
- url.searchParams.set(WEBVIEW_START_TIME_PARAM, Date.now().toString());
652
- return url;
653
- };
654
-
655
216
  const waasOrigins = ['https://app.dynamic-preprod.xyz', 'https://app.dynamicauth.com'];
656
217
  const turnkeyOrigins = ['https://recovery.turnkey.com', 'https://export.turnkey.com'];
657
218
  const isNonProductionBuild = () => process.env['NODE_ENV'] !== 'production';
@@ -686,158 +247,153 @@ const shouldAllowNavigation = (request, webViewUrl) => {
686
247
  return false;
687
248
  };
688
249
 
689
- const DEFAULT_RECOVERY_TIMEOUT_MS$1 = 20000;
690
- const DEFAULT_LOADING_TIMEOUT_MS$1 = 20000;
250
+ const REACT_NATIVE_WEBVIEW_TYPE = 'react_native_webview';
251
+ // react-native-webview's `onError` only carries a `domain` on iOS/macOS.
252
+ // When it is absent (Android) this stable literal stands in so
253
+ // `classifyWebViewLoadError` still receives a domain and classifies the
254
+ // failure by its code alone (unknown domains default to transient).
255
+ const REACT_NATIVE_WEBVIEW_LOAD_ERROR_DOMAIN = 'ReactNativeWebViewLoadError';
256
+ const SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.webview.successLogLastEmittedAt';
257
+ // All load, timeout, retry, telemetry, and terminal-error logic is owned by
258
+ // the webview load controller (`createWebViewLoadController`). This
259
+ // component only owns the platform bindings: the react-native-webview
260
+ // element, its lifecycle-event mapping, the message bridge, visibility
261
+ // handling, and the navigation allowlist.
691
262
  const WebView = ({
692
- webviewUrl: initialWebViewUrl,
263
+ webviewUrl,
693
264
  core,
694
265
  webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
695
- disableRecovery: _disableRecovery = false,
696
- recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS$1,
697
- loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS$1
266
+ recoveryTimeout,
267
+ loadingTimeout,
268
+ webviewLoad
698
269
  }) => {
699
270
  const webViewRef = useRef(null);
700
271
  const {
701
272
  visible
702
273
  } = useWebViewVisibility(core);
703
- const [webViewUrl, setWebViewUrl] = useState(() => assignStartTimeToUrl(assignEnvironmentIdToUrl(initialWebViewUrl, core.environmentId)));
274
+ // The URL of the load attempt currently in flight. The controller owns
275
+ // URL construction (environmentId, start time, retry counter); loading a
276
+ // new URL remounts the webview via `key`, which is how reloads work on
277
+ // this path. `null` until the controller issues the first load.
278
+ const [currentAttemptUrl, setCurrentAttemptUrl] = useState(null);
279
+ const controllerRef = useRef(null);
704
280
  const {
705
281
  onMessageHandler
706
282
  } = useMessageTransportWebViewBridge(core, webViewRef);
707
- const {
708
- recordEvent,
709
- getMeta,
710
- getSuccessMeta
711
- } = useWebViewPhaseTimers({
712
- core,
713
- loadingTimeout: _loadingTimeout,
714
- recoveryTimeout: _recoveryTimeout,
715
- webViewUrl
716
- });
717
- useWebViewSuccessLog({
718
- core,
719
- getSuccessMeta
720
- });
721
- const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
722
283
  /**
723
- * Block the message transport when the webview is unmounted
284
+ * Owns the load-controller lifecycle: created once per mount, torn down
285
+ * on unmount (keeping the unmount warning + transport block so a stray
286
+ * unmount doesn't leave host messages hitting a dead page).
724
287
  */
725
- useEffect(() => () => {
726
- logger.warn(unmountWebViewWarningMessage);
727
- core.messageTransport.block();
728
- },
729
- // eslint-disable-next-line react-hooks/exhaustive-deps -- we only care about the unmount callback
730
- []);
731
- const blockAndReloadWebView = useCallback(reason => {
732
- /**
733
- * Should not reload the webview if it is already blocked
734
- * and loading a new page
735
- */
736
- if (core.messageTransport.isBlocked()) {
737
- return;
738
- }
739
- /**
740
- * iOS and Android both may trigger this event when the webview
741
- * is removed due to memory pressure.
742
- *
743
- * When the webview is killed by the OS, the message transport must
744
- * be blocked to prevent the "No handlers registered" error while
745
- * the webview is reloading
746
- */
747
- core.messageTransport.block();
748
- setWebViewUrl(webViewUrl => {
749
- const newWebViewUrl = increaseRetryToUrl(webViewUrl);
750
- logger.instrument('Reloading webview', {
751
- environmentId: core.environmentId,
752
- hostSdkSessionId: core.hostSdkSessionId,
753
- key: 'webview.reloading',
754
- newWebViewUrl,
755
- reason,
756
- time: 0,
757
- webviewUrl: webViewUrl.toString()
758
- });
759
- return newWebViewUrl;
288
+ useEffect(() => {
289
+ const adapter = {
290
+ load: url => setCurrentAttemptUrl(url),
291
+ webviewType: REACT_NATIVE_WEBVIEW_TYPE
292
+ };
293
+ const successLogCooldown = createSuccessLogCooldown({
294
+ name: 'webview',
295
+ storageKey: SUCCESS_LOG_COOLDOWN_STORAGE_KEY
760
296
  });
761
- }, [core]);
762
- const blockAndReloadWebViewOsKill = useCallback(() => {
763
- recordEvent('os_kill');
764
- blockAndReloadWebView('os_kill');
765
- }, [blockAndReloadWebView, recordEvent]);
766
- useEffect(() => core.messageTransport.recoveryManager.onRecoveryRequested(() => blockAndReloadWebView('recovery')), [core, blockAndReloadWebView]);
767
- const setWebViewLoadError = useCallback(phase => {
768
- // Error was already thrown, do not throw again
769
- if (core.initialization.error instanceof WebViewFailedToLoadError) return;
770
- core.initialization.error = new WebViewFailedToLoadError(getMeta({
771
- phase
297
+ const controller = createWebViewLoadController(Object.assign(Object.assign(Object.assign(Object.assign({
298
+ adapter,
299
+ core
300
+ }, webviewLoad), loadingTimeout !== undefined && {
301
+ pageLoadTimeoutMs: loadingTimeout
302
+ }), recoveryTimeout !== undefined && {
303
+ sdkReadyTimeoutMs: recoveryTimeout
304
+ }), {
305
+ // The controller exposes no post-emit hook, so the cooldown is
306
+ // recorded as soon as the gate passes — before the log leaves the
307
+ // device. Worst case (a teardown between gate and emit) one log is
308
+ // suppressed, which beats replaying it after a crash.
309
+ shouldEmitSuccessLog: () => __awaiter(void 0, void 0, void 0, function* () {
310
+ if (!(yield successLogCooldown.shouldEmit())) return false;
311
+ yield successLogCooldown.recordEmitted();
312
+ return true;
313
+ }),
314
+ webviewUrl
772
315
  }));
773
- }, [core, getMeta]);
774
- /**
775
- * Wraps `setWebViewLoadError` so the consumer doesn't need to know
776
- * about the phase enum; each callsite below binds its own phase.
777
- */
778
- const setWebViewLoadErrorWithPhase = useCallback(phase => () => setWebViewLoadError(phase), [setWebViewLoadError]);
316
+ controllerRef.current = controller;
317
+ // Hands the controller to the client core, which wires the
318
+ // customer-facing `client.sdk.loadState` / `client.sdk.retry()`
319
+ // surface to it.
320
+ core.registerWebViewLoadController(controller);
321
+ controller.start();
322
+ return () => {
323
+ logger.warn(unmountWebViewWarningMessage);
324
+ controller.teardown();
325
+ controllerRef.current = null;
326
+ core.messageTransport.block();
327
+ };
328
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the controller lifecycle is strictly mount/unmount
329
+ }, []);
330
+ const dispatch = event => {
331
+ var _a;
332
+ return (_a = controllerRef.current) === null || _a === void 0 ? void 0 : _a.dispatch(event);
333
+ };
334
+ const onError = ({
335
+ nativeEvent
336
+ }) => {
337
+ var _a;
338
+ return dispatch({
339
+ code: nativeEvent.code,
340
+ description: nativeEvent.description,
341
+ domain: (_a = nativeEvent.domain) !== null && _a !== void 0 ? _a : REACT_NATIVE_WEBVIEW_LOAD_ERROR_DOMAIN,
342
+ failedUrl: nativeEvent.url,
343
+ type: 'native_error'
344
+ });
345
+ };
346
+ const onHttpError = ({
347
+ nativeEvent
348
+ }) => dispatch({
349
+ code: nativeEvent.statusCode,
350
+ description: nativeEvent.description,
351
+ domain: EMBEDDED_WEBVIEW_HTTP_ERROR_DOMAIN,
352
+ failedUrl: nativeEvent.url,
353
+ type: 'native_error'
354
+ });
779
355
  /**
780
- * Reload the webview with a clean state when a timeout is reached
781
- * and the webview did not get to the loaded state yet
356
+ * iOS and Android both may kill the webview content process under memory
357
+ * pressure. The controller routes the signal through the bounded
358
+ * backed-off reload cycle, blocking the message transport while the
359
+ * reload is in flight (the machine handles it both mid-load and
360
+ * post-ready).
782
361
  */
783
- const startRecoveryTimeout = useWebViewRecoveryTimeout({
784
- core,
785
- disableRecovery: _disableRecovery,
786
- onFailedToLoadAfterClearState: setWebViewLoadErrorWithPhase('after_clear_state'),
787
- recoveryTimeout: _recoveryTimeout,
788
- setWebViewUrl,
789
- webViewUrl
362
+ const onProcessTerminated = () => dispatch({
363
+ type: 'recovery_requested'
790
364
  });
791
- const webViewLoadErrorCountRef = useRef(0);
792
- const onWebViewLoadError = useCallback(() => {
793
- webViewLoadErrorCountRef.current = webViewLoadErrorCountRef.current + 1;
794
- recordEvent('native_error');
795
- /**
796
- * This is the first attempt to load the webview, do not throw an error
797
- * because the recovery system will attempt to reload the webview
798
- */
799
- if (webViewLoadErrorCountRef.current === 1) return;
800
- setWebViewLoadError('native_error');
801
- }, [recordEvent, setWebViewLoadError]);
802
- const {
803
- onLoad: onLoadFromTimeout,
804
- onLoadStart: onLoadStartFromTimeout
805
- } = useWebViewLoadingTimeout(_loadingTimeout, setWebViewLoadErrorWithPhase('html_load'));
806
- const onLoadStart = useCallback(() => {
807
- recordEvent('load_start');
808
- onLoadStartFromTimeout();
809
- }, [onLoadStartFromTimeout, recordEvent]);
810
- const onLoad = useCallback(() => {
811
- recordEvent('load');
812
- onLoadFromTimeout();
813
- }, [onLoadFromTimeout, recordEvent]);
814
- const onLoadEnd = useCallback(() => {
815
- recordEvent('load_end');
816
- startRecoveryTimeout();
817
- }, [recordEvent, startRecoveryTimeout]);
818
- const webViewUrlString = useMemo(() => webViewUrl.toString(), [webViewUrl]);
365
+ const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
366
+ if (currentAttemptUrl === null) return null;
367
+ const currentAttemptUrlString = currentAttemptUrl.toString();
819
368
  return /*#__PURE__*/jsx(WebView$1, {
820
369
  ref: webViewRef,
821
370
  source: {
822
- uri: webViewUrlString
371
+ uri: currentAttemptUrlString
823
372
  },
824
373
  containerStyle: containerStyles,
825
374
  style: styles['webview'],
826
375
  onMessage: onMessageHandler,
827
- onLoadEnd: onLoadEnd,
828
- onLoadStart: onLoadStart,
829
- onLoad: onLoad,
376
+ onLoadStart: () => dispatch({
377
+ type: 'load_started'
378
+ }),
379
+ onLoad: () => dispatch({
380
+ type: 'response_committed'
381
+ }),
382
+ onLoadEnd: () => dispatch({
383
+ type: 'page_finished'
384
+ }),
830
385
  hideKeyboardAccessoryView: true,
831
386
  webviewDebuggingEnabled: _webviewDebuggingEnabled,
832
- onContentProcessDidTerminate: blockAndReloadWebViewOsKill,
833
- onRenderProcessGone: blockAndReloadWebViewOsKill,
834
- onError: onWebViewLoadError,
387
+ onContentProcessDidTerminate: onProcessTerminated,
388
+ onRenderProcessGone: onProcessTerminated,
389
+ onError: onError,
390
+ onHttpError: onHttpError,
835
391
  setSupportMultipleWindows: false,
836
392
  onShouldStartLoadWithRequest: request => shouldAllowNavigation({
837
393
  isTopFrame: request.isTopFrame,
838
394
  url: request.url
839
- }, webViewUrl)
840
- }, webViewUrlString);
395
+ }, currentAttemptUrl)
396
+ }, currentAttemptUrlString);
841
397
  };
842
398
  const createWebView = internalProps => {
843
399
  const WebViewWrapper = props => /*#__PURE__*/jsx(WebView, _extends({}, internalProps, props));
@@ -896,53 +452,10 @@ const getEmbeddedWebView = () => {
896
452
  }
897
453
  };
898
454
 
899
- /**
900
- * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
901
- * webview path. The embedded path is a singleton wired by
902
- * `setupEmbeddedWebView` (not a React component), so we expose the same
903
- * phase-timing surface as a plain factory.
904
- *
905
- * Both this factory and the hook delegate the actual state machine to
906
- * {@link createWebViewPhaseTimerCore}; the only path-specific bits are:
907
- *
908
- * - `hadClearState` is always `false` (embedded path has no clear-state
909
- * recovery model — that's exclusive to the React `<WebView>` path's
910
- * loading-timeout recovery flow)
911
- * - `retryCount` is always `0` (embedded path has no retry counter URL
912
- * query param either)
913
- */
914
- const createEmbeddedWebViewPhaseTimers = ({
915
- core,
916
- loadingTimeoutMs,
917
- recoveryTimeoutMs,
918
- webViewUrl
919
- }) => {
920
- const phaseCore = createWebViewPhaseTimerCore({
921
- core
922
- });
923
- const getSuccessMeta = () => buildSuccessMeta({
924
- core: phaseCore,
925
- hadClearState: false,
926
- loadingTimeoutMs,
927
- recoveryTimeoutMs,
928
- retryCount: 0,
929
- webviewUrl: webViewUrl.toString()
930
- });
931
- return {
932
- dispose: phaseCore.dispose,
933
- getMeta: ({
934
- phase
935
- }) => Object.assign(Object.assign({}, getSuccessMeta()), {
936
- phase
937
- }),
938
- getSuccessMeta,
939
- recordEvent: phaseCore.recordEvent
940
- };
941
- };
942
-
943
455
  const EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.embeddedWebView.successLogLastEmittedAt';
944
- const DEFAULT_LOADING_TIMEOUT_MS = 20000;
945
- const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
456
+ // The platform discriminator the load controller attaches to every
457
+ // consolidated `webview.load_*` telemetry event emitted for this path.
458
+ const EMBEDDED_WEBVIEW_TYPE = 'embedded_native';
946
459
  // Debug log labels matching the react-native-webview bridge
947
460
  // (`useMessageTransportWebViewBridge`) so both paths surface the same
948
461
  // HOST/WEBVIEW message trace under `core.debug.messageTransport`.
@@ -953,6 +466,11 @@ const WEBVIEW_TO_HOST_LOG = 'WEBVIEW >>>> HOST';
953
466
  // outside the React tree (in a dedicated UIWindow on iOS), so its setup runs
954
467
  // once when the SDK is initialised — there's nothing to mount or unmount.
955
468
  //
469
+ // All load, timeout, retry, telemetry, and terminal-error logic is owned by
470
+ // the webview load controller (`createWebViewLoadController`). This module
471
+ // only owns the platform bindings: the native-module wiring, the message
472
+ // bridge, visibility handling, and the navigation allowlist.
473
+ //
956
474
  // Returns a teardown that removes every JS-side subscription. The native
957
475
  // singleton is intentionally NOT destroyed — re-running setup with a fresh
958
476
  // `core` (e.g. on client recreation with a new environmentId) just reloads
@@ -961,61 +479,48 @@ const setupEmbeddedWebView = ({
961
479
  webviewUrl,
962
480
  core,
963
481
  webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
964
- loadingTimeoutMs: _loadingTimeoutMs = DEFAULT_LOADING_TIMEOUT_MS,
965
- recoveryTimeoutMs: _recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS
482
+ webviewLoad
966
483
  }) => {
967
484
  const native = getEmbeddedWebView();
968
- const builtUrl = assignStartTimeToUrl(assignEnvironmentIdToUrl(webviewUrl, core.environmentId));
969
- const phaseTimers = createEmbeddedWebViewPhaseTimers({
970
- core,
971
- loadingTimeoutMs: _loadingTimeoutMs,
972
- recoveryTimeoutMs: _recoveryTimeoutMs,
973
- webViewUrl: builtUrl
974
- });
485
+ // The URL of the load attempt currently in flight. The controller owns
486
+ // URL construction (environmentId, start time, retry counter), so the
487
+ // navigation allowlist below must compare against the URL the native
488
+ // webview was actually pointed at. Until the first `adapter.load` the
489
+ // base URL stands in — only its origin is ever compared.
490
+ let currentAttemptUrl = new URL(webviewUrl);
491
+ const adapter = {
492
+ load: url => {
493
+ var _a;
494
+ currentAttemptUrl = url;
495
+ logger.debug(`[EmbeddedWebView] calling setUrl: ${url.toString()}`);
496
+ (_a = native.setUrl(url.toString())) === null || _a === void 0 ? void 0 : _a.catch(err => {
497
+ logger.warn('EmbeddedWebView.setUrl failed', err);
498
+ });
499
+ },
500
+ webviewType: EMBEDDED_WEBVIEW_TYPE
501
+ };
975
502
  const successLogCooldown = createSuccessLogCooldown({
976
503
  name: 'embedded webview',
977
504
  storageKey: EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY
978
505
  });
979
- let loadingTimer = null;
980
- let recoveryTimer = null;
981
- let hasFailed = false;
982
- let hasEmittedSuccessLog = false;
983
- let tornDown = false;
984
- const clearLoadingTimer = () => {
985
- if (loadingTimer !== null) {
986
- clearTimeout(loadingTimer);
987
- loadingTimer = null;
988
- }
989
- };
990
- const clearRecoveryTimer = () => {
991
- if (recoveryTimer !== null) {
992
- clearTimeout(recoveryTimer);
993
- recoveryTimer = null;
994
- }
995
- };
996
- // Raise `WebViewFailedToLoadError` once per setup. Subsequent triggers
997
- // (e.g. recovery timeout firing after html_load already failed) are
998
- // suppressed so we don't spam the initialization-error setter with
999
- // duplicate logs for the same load attempt.
1000
- const raiseFailure = (phase, extraMeta = {}) => {
1001
- if (hasFailed) return;
1002
- hasFailed = true;
1003
- clearLoadingTimer();
1004
- clearRecoveryTimer();
1005
- logger.debug(`[EmbeddedWebView] raiseFailure phase=${phase}`);
1006
- const meta = phaseTimers.getMeta({
1007
- phase
1008
- });
1009
- logger.instrument('Embedded webview load error', Object.assign({
1010
- environmentId: core.environmentId,
1011
- hostSdkSessionId: core.hostSdkSessionId,
1012
- key: 'webview.embedded.load_error',
1013
- phase,
1014
- time: 0,
1015
- webviewUrl: builtUrl.toString()
1016
- }, extraMeta));
1017
- core.initialization.error = new WebViewFailedToLoadError(Object.assign(Object.assign({}, meta), extraMeta));
1018
- };
506
+ const controller = createWebViewLoadController(Object.assign(Object.assign({
507
+ adapter,
508
+ core
509
+ }, webviewLoad), {
510
+ // The controller exposes no post-emit hook, so the cooldown is recorded
511
+ // as soon as the gate passes — before the log leaves the device. Worst
512
+ // case (a teardown between gate and emit) one log is suppressed, which
513
+ // beats replaying it after a crash.
514
+ shouldEmitSuccessLog: () => __awaiter(void 0, void 0, void 0, function* () {
515
+ if (!(yield successLogCooldown.shouldEmit())) return false;
516
+ yield successLogCooldown.recordEmitted();
517
+ return true;
518
+ }),
519
+ webviewUrl
520
+ }));
521
+ // Hands the controller to the client core, which wires the customer-facing
522
+ // `client.sdk.loadState` / `client.sdk.retry()` surface to it.
523
+ core.registerWebViewLoadController(controller);
1019
524
  const visibilityChannel = createRequestChannel(core.messageTransport);
1020
525
  const removeVisibilityHandler = visibilityChannel.handle('setVisibility', visible => {
1021
526
  native.setVisible(visible).catch(err => {
@@ -1033,31 +538,6 @@ const setupEmbeddedWebView = ({
1033
538
  });
1034
539
  };
1035
540
  core.messageTransport.on(handleHostMessage);
1036
- // Watch for the webview-side SDK ready signal so we can clear the
1037
- // recovery timer and emit the success log. The phase-timer factory
1038
- // also subscribes for its own bookkeeping — both subscriptions are
1039
- // independent and idempotent. The factory subscribes first and so
1040
- // sees `sdkReadyAt` before this handler runs, which means the meta
1041
- // we emit below has the freshest `sdkReadyMs`. We only emit once
1042
- // per setup; a second SDK-ready event for the same load wouldn't
1043
- // represent a new successful boot.
1044
- const handleSdkReady = message => {
1045
- if (message.origin !== 'webview') return;
1046
- if (message.type !== sdkHasLoadedEventName) return;
1047
- logger.debug('[EmbeddedWebView] SDK ready signal received');
1048
- clearRecoveryTimer();
1049
- if (hasEmittedSuccessLog || hasFailed) return;
1050
- hasEmittedSuccessLog = true;
1051
- emitSuccessLog({
1052
- cooldown: successLogCooldown,
1053
- core,
1054
- getMeta: phaseTimers.getSuccessMeta,
1055
- name: 'Embedded webview'
1056
- }).catch(err => {
1057
- logger.warn('EmbeddedWebView.emitSuccessLog failed', err);
1058
- });
1059
- };
1060
- core.messageTransport.on(handleSdkReady);
1061
541
  const onMessageSub = native.addListener('onMessage', event => {
1062
542
  var _a;
1063
543
  let parsed = null;
@@ -1079,7 +559,7 @@ const setupEmbeddedWebView = ({
1079
559
  const allow = shouldAllowNavigation({
1080
560
  isTopFrame: event.isTopFrame,
1081
561
  url: event.url
1082
- }, builtUrl);
562
+ }, currentAttemptUrl);
1083
563
  logger.debug(`[EmbeddedWebView] onShouldStartLoad id=${event.id} url=${event.url} isTopFrame=${event.isTopFrame} allow=${allow}`);
1084
564
  native.respondToShouldStartLoad(event.id, allow).catch(err => {
1085
565
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
@@ -1087,86 +567,57 @@ const setupEmbeddedWebView = ({
1087
567
  });
1088
568
  const onLoadStartSub = native.addListener('onLoadStart', () => {
1089
569
  logger.debug('[EmbeddedWebView] onLoadStart');
1090
- phaseTimers.recordEvent('load_start');
1091
- // Re-arm both timers on every load_start. A retry from the native
1092
- // side (e.g. a redirect, or a reload after a transient failure)
1093
- // resets the html_load window cleanly without leaving a stale timer
1094
- // around from the previous attempt.
1095
- clearLoadingTimer();
1096
- clearRecoveryTimer();
1097
- loadingTimer = setTimeout(() => {
1098
- raiseFailure('html_load');
1099
- }, _loadingTimeoutMs);
570
+ controller.dispatch({
571
+ type: 'load_started'
572
+ });
1100
573
  });
1101
574
  const onLoadSub = native.addListener('onLoad', () => {
1102
575
  logger.debug('[EmbeddedWebView] onLoad (response committed)');
1103
- phaseTimers.recordEvent('load');
1104
- clearLoadingTimer();
576
+ controller.dispatch({
577
+ type: 'response_committed'
578
+ });
1105
579
  });
1106
580
  const onLoadEndSub = native.addListener('onLoadEnd', () => {
1107
581
  logger.debug('[EmbeddedWebView] onLoadEnd (page finished)');
1108
- phaseTimers.recordEvent('load_end');
1109
- clearLoadingTimer();
1110
- clearRecoveryTimer();
1111
- recoveryTimer = setTimeout(() => {
1112
- raiseFailure('sdk_bootstrap');
1113
- }, _recoveryTimeoutMs);
582
+ controller.dispatch({
583
+ type: 'page_finished'
584
+ });
1114
585
  });
1115
586
  const onLoadErrorSub = native.addListener('onLoadError', event => {
1116
587
  logger.debug(`[EmbeddedWebView] onLoadError domain=${event.domain} code=${event.code} url=${event.url}`);
1117
- phaseTimers.recordEvent('native_error');
1118
588
  logger.warn('EmbeddedWebView load error', event);
1119
- raiseFailure('embedded_native_error', {
1120
- nativeErrorCode: event.code,
1121
- nativeErrorDescription: event.description,
1122
- nativeErrorDomain: event.domain,
1123
- nativeErrorFailedUrl: event.url,
1124
- nativeErrorIsProvisional: event.isProvisional
589
+ controller.dispatch({
590
+ code: event.code,
591
+ description: event.description,
592
+ domain: event.domain,
593
+ failedUrl: event.url,
594
+ isProvisional: event.isProvisional,
595
+ type: 'native_error'
1125
596
  });
1126
597
  });
1127
598
  native.setDebuggingEnabled(_webviewDebuggingEnabled).catch(err => {
1128
599
  logger.warn('EmbeddedWebView.setDebuggingEnabled failed', err);
1129
600
  });
1130
- // Arm the loading timer before calling setUrl so the pre-onLoadStart window
1131
- // is covered. On cold boot the native 2s navigation-decision timeout can
1132
- // fire before JS responds to onShouldStartLoad, silently cancelling the load
1133
- // without emitting onLoadError leaving onLoadStart (and therefore the
1134
- // timer) never triggered. The onLoadStart handler clears and re-arms this
1135
- // timer when it fires, so behaviour after onLoadStart is unchanged.
1136
- logger.debug(`[EmbeddedWebView] arming loading timer (${_loadingTimeoutMs}ms) before setUrl`);
1137
- loadingTimer = setTimeout(() => {
1138
- logger.debug('[EmbeddedWebView] loading timer fired — raising html_load failure');
1139
- raiseFailure('html_load');
1140
- }, _loadingTimeoutMs);
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
601
+ // Defer the initial load by one event-loop tick so the JS thread has fully
602
+ // settled after listener registration. On cold boot the main thread is
603
+ // congested with bundle evaluation yielding here gives pending microtasks
604
+ // and native bridge batches a chance to flush before the native side starts
1145
605
  // firing navigation events that require a JS round-trip response.
1146
606
  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
- });
607
+ controller.start();
1153
608
  }, 0);
1154
609
  return () => {
1155
610
  logger.debug('[EmbeddedWebView] teardown — removing listeners');
1156
- tornDown = true;
1157
611
  clearTimeout(warmupTimer);
1158
- clearLoadingTimer();
1159
- clearRecoveryTimer();
612
+ controller.teardown();
1160
613
  removeVisibilityHandler();
1161
614
  core.messageTransport.off(handleHostMessage);
1162
- core.messageTransport.off(handleSdkReady);
1163
615
  onMessageSub.remove();
1164
616
  onShouldStartLoadSub.remove();
1165
617
  onLoadStartSub.remove();
1166
618
  onLoadSub.remove();
1167
619
  onLoadEndSub.remove();
1168
620
  onLoadErrorSub.remove();
1169
- phaseTimers.dispose();
1170
621
  };
1171
622
  };
1172
623
 
@@ -1510,7 +961,8 @@ const ReactNativeExtension = ({
1510
961
  webviewUrl: _webviewUrl = defaultWebviewUrl,
1511
962
  webviewDebuggingEnabled,
1512
963
  appOrigin,
1513
- embeddedWebView: _embeddedWebView = false
964
+ embeddedWebView: _embeddedWebView = false,
965
+ webviewLoad
1514
966
  } = {}) => (_, core) => {
1515
967
  const isPlatformSupportedByWebView = Platform.OS === 'android' || Platform.OS === 'ios';
1516
968
  /**
@@ -1561,6 +1013,7 @@ const ReactNativeExtension = ({
1561
1013
  previousEmbeddedWebViewTeardown = setupEmbeddedWebView({
1562
1014
  core,
1563
1015
  webviewDebuggingEnabled,
1016
+ webviewLoad,
1564
1017
  webviewUrl: _webviewUrl
1565
1018
  });
1566
1019
  } catch (err) {
@@ -1581,6 +1034,7 @@ const ReactNativeExtension = ({
1581
1034
  WebView: createWebView({
1582
1035
  core,
1583
1036
  webviewDebuggingEnabled,
1037
+ webviewLoad,
1584
1038
  webviewUrl: _webviewUrl
1585
1039
  })
1586
1040
  }
@@ -1589,4 +1043,4 @@ const ReactNativeExtension = ({
1589
1043
 
1590
1044
  assertPackageVersion('@dynamic-labs/react-native-extension', version);
1591
1045
 
1592
- export { ReactNativeExtension, WebViewFailedToLoadError };
1046
+ export { ReactNativeExtension };