@dynamic-labs/react-native-extension 4.83.1 → 4.84.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 (32) hide show
  1. package/android/EmbeddedWebViewController.kt +26 -0
  2. package/android/EmbeddedWebViewModule.kt +8 -1
  3. package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  4. package/android/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  5. package/android/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  6. package/android/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  7. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  8. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  9. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  10. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  11. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  12. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  13. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +26 -0
  14. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +8 -1
  15. package/index.cjs +601 -57
  16. package/index.js +602 -58
  17. package/ios/EmbeddedWebViewController.swift +22 -0
  18. package/ios/EmbeddedWebViewModule.swift +8 -1
  19. package/package.json +6 -6
  20. package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +20 -1
  21. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/embeddedWebViewPhaseTimers.d.ts +66 -0
  22. package/src/components/WebView/EmbeddedWebView/embeddedWebViewPhaseTimers/index.d.ts +1 -0
  23. package/src/components/WebView/successLog/emitSuccessLog.d.ts +35 -0
  24. package/src/components/WebView/successLog/index.d.ts +3 -0
  25. package/src/components/WebView/successLog/successLogCooldown.d.ts +39 -0
  26. package/src/components/WebView/useWebViewPhaseTimers/index.d.ts +1 -0
  27. package/src/components/WebView/useWebViewPhaseTimers/useWebViewPhaseTimers.d.ts +46 -0
  28. package/src/components/WebView/useWebViewSuccessLog/index.d.ts +1 -0
  29. package/src/components/WebView/useWebViewSuccessLog/useWebViewSuccessLog.d.ts +28 -0
  30. package/src/components/WebView/webViewPhaseTimerCore/index.d.ts +2 -0
  31. package/src/components/WebView/webViewPhaseTimerCore/webViewPhaseTimerCore.d.ts +123 -0
  32. package/src/nativeModules/EmbeddedWebView.d.ts +27 -2
package/index.cjs CHANGED
@@ -8,12 +8,13 @@ var react = require('react');
8
8
  var reactNativeWebview = require('react-native-webview');
9
9
  var logger$1 = require('@dynamic-labs/logger');
10
10
  var messageTransport = require('@dynamic-labs/message-transport');
11
+ var webviewMessages = require('@dynamic-labs/webview-messages');
12
+ var expoSecureStore = require('expo-secure-store');
11
13
  var jsxRuntime = require('react/jsx-runtime');
12
14
  var expoModulesCore = require('expo-modules-core');
13
15
  var reactNativePasskey = require('react-native-passkey');
14
16
  var expoLinking = require('expo-linking');
15
17
  var expoWebBrowser = require('expo-web-browser');
16
- var expoSecureStore = require('expo-secure-store');
17
18
  var reactNativePasskeyStamper = require('@turnkey/react-native-passkey-stamper');
18
19
 
19
20
  function _interopNamespace(e) {
@@ -34,7 +35,7 @@ function _interopNamespace(e) {
34
35
  return Object.freeze(n);
35
36
  }
36
37
 
37
- var version = "4.83.1";
38
+ var version = "4.84.0";
38
39
 
39
40
  function _extends() {
40
41
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -122,6 +123,396 @@ const useMessageTransportWebViewBridge = (core, webViewRef) => {
122
123
  };
123
124
  };
124
125
 
126
+ const CLEAR_STATE_PARAM = 'clear-state';
127
+ const ENVIRONMENT_ID_PARAM = 'environmentId';
128
+ const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
129
+
130
+ const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
131
+
132
+ /**
133
+ * Wire-format `type` of a webview-side `manifest` request.
134
+ *
135
+ * `requestChannel.request('manifest')` sends a `MessageTransportData` whose
136
+ * `type` is the literal request name. The host replies with `manifest__ack`,
137
+ * which we ignore: receiving the request is sufficient evidence that the
138
+ * webview JS bundle is alive and the message bridge works.
139
+ */
140
+ const MANIFEST_MESSAGE_TYPE = 'manifest';
141
+ const emptyTimings = () => ({
142
+ htmlLoadStartedAt: null,
143
+ htmlLoadedAt: null,
144
+ manifestReceivedAt: null,
145
+ onLoadEndAt: null,
146
+ sdkReadyAt: null
147
+ });
148
+ const diffMs = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
149
+ /**
150
+ * Derive cross-phase durations from raw timestamps. The four durations
151
+ * are what end up in the failure / success log meta:
152
+ *
153
+ * - `htmlLoadMs` — `onLoadStart` to `onLoad`; native HTML fetch
154
+ * - `onLoadToOnLoadEndMs` — `onLoad` to `onLoadEnd`; native render/parse
155
+ * - `manifestReceivedMs` — `onLoadEnd` to first `manifest` request from
156
+ * the webview JS; "JS bundle is alive" signal
157
+ * - `sdkReadyMs` — `onLoadEnd` to first `sdkHasLoaded` event from the
158
+ * webview JS; "SDK fully bootstrapped" signal
159
+ */
160
+ const computePhaseDurations = timings => ({
161
+ htmlLoadMs: diffMs(timings.htmlLoadedAt, timings.htmlLoadStartedAt),
162
+ manifestReceivedMs: diffMs(timings.manifestReceivedAt, timings.onLoadEndAt),
163
+ onLoadToOnLoadEndMs: diffMs(timings.onLoadEndAt, timings.htmlLoadedAt),
164
+ sdkReadyMs: diffMs(timings.sdkReadyAt, timings.onLoadEndAt)
165
+ });
166
+ /**
167
+ * Build the success-shape meta from a {@link WebViewPhaseTimerCore}
168
+ * snapshot plus the path-specific fields. Both call sites call this
169
+ * with their own context (RN reads `hadClearState` / `retryCount` from
170
+ * the URL; embedded passes constants) so the success-log payload stays
171
+ * identical across the two paths.
172
+ */
173
+ const buildSuccessMeta = ({
174
+ core,
175
+ hadClearState,
176
+ loadingTimeoutMs,
177
+ recoveryTimeoutMs,
178
+ retryCount,
179
+ webviewUrl
180
+ }) => Object.assign(Object.assign({
181
+ hadClearState,
182
+ loadingTimeoutMs,
183
+ recoveryTimeoutMs,
184
+ retryCount,
185
+ webviewUrl
186
+ }, computePhaseDurations(core.getTimings())), core.getCounters());
187
+ /**
188
+ * Shared state machine driving WebView load-phase instrumentation.
189
+ *
190
+ * Both the React `<WebView>` (RN bridge) and the native embedded WebView
191
+ * (native bridge) feed lifecycle events into this core and produce
192
+ * matching meta from the same timings + counters. Each path layers its
193
+ * own path-specific fields (`hadClearState`, `retryCount`, etc.) on top
194
+ * of the raw state this core exposes via `getTimings()` / `getCounters()`.
195
+ *
196
+ * The core owns the `core.messageTransport` subscription that captures
197
+ * the two webview-originated signals bracketing SDK bootstrap
198
+ * (`manifest` once the JS bundle is alive; `sdkHasLoadedEventName`
199
+ * once the SDK is fully ready). Native bridge lifecycle events come in
200
+ * via `recordEvent` instead.
201
+ */
202
+ const createWebViewPhaseTimerCore = ({
203
+ core
204
+ }) => {
205
+ let timings = emptyTimings();
206
+ let nativeErrorCount = 0;
207
+ let osKillCount = 0;
208
+ /**
209
+ * We only capture the first occurrence per attempt so that a healthy
210
+ * reload doesn't overwrite the timing from the attempt that ultimately
211
+ * failed.
212
+ */
213
+ const handleMessage = message => {
214
+ if (message.origin !== 'webview') return;
215
+ if (message.type === MANIFEST_MESSAGE_TYPE && timings.manifestReceivedAt === null) {
216
+ timings.manifestReceivedAt = Date.now();
217
+ }
218
+ if (message.type === webviewMessages.sdkHasLoadedEventName && timings.sdkReadyAt === null) {
219
+ timings.sdkReadyAt = Date.now();
220
+ }
221
+ };
222
+ core.messageTransport.on(handleMessage);
223
+ return {
224
+ dispose: () => {
225
+ core.messageTransport.off(handleMessage);
226
+ },
227
+ getCounters: () => ({
228
+ nativeErrorCount,
229
+ osKillCount
230
+ }),
231
+ getTimings: () => timings,
232
+ recordEvent: event => {
233
+ const now = Date.now();
234
+ switch (event) {
235
+ case 'load_start':
236
+ timings = emptyTimings();
237
+ timings.htmlLoadStartedAt = now;
238
+ break;
239
+ case 'load':
240
+ timings.htmlLoadedAt = now;
241
+ break;
242
+ case 'load_end':
243
+ timings.onLoadEndAt = now;
244
+ break;
245
+ case 'native_error':
246
+ nativeErrorCount += 1;
247
+ break;
248
+ case 'os_kill':
249
+ osKillCount += 1;
250
+ break;
251
+ }
252
+ }
253
+ };
254
+ };
255
+
256
+ const RETRY_QUERY_PARAM = 'retry';
257
+ const readRetryCount = webViewUrl => {
258
+ const raw = webViewUrl.searchParams.get(RETRY_QUERY_PARAM);
259
+ if (raw === null) return 0;
260
+ const parsed = Number(raw);
261
+ return Number.isFinite(parsed) ? parsed : 0;
262
+ };
263
+ /**
264
+ * Snapshot returned by the phase-timer core when it hasn't been
265
+ * instantiated yet (briefly true between hook construction and the
266
+ * `useEffect` running). Lets `getSuccessMeta` / `getMeta` produce a
267
+ * well-formed empty meta in that window without special-casing each
268
+ * field.
269
+ */
270
+ const EMPTY_CORE_SNAPSHOT = {
271
+ getCounters: () => ({
272
+ nativeErrorCount: 0,
273
+ osKillCount: 0
274
+ }),
275
+ getTimings: () => ({
276
+ htmlLoadStartedAt: null,
277
+ htmlLoadedAt: null,
278
+ manifestReceivedAt: null,
279
+ onLoadEndAt: null,
280
+ sdkReadyAt: null
281
+ })
282
+ };
283
+ /**
284
+ * Tracks how long each step of the WebView load took, so when we raise
285
+ * `WebViewFailedToLoadError` we can attach the timings to the error log.
286
+ *
287
+ * The timings cover the host-side phases of the load — the webview-side
288
+ * timings (`webview.time_to_load_manifest`, `webview.time_to_sdk_ready`)
289
+ * still come from the webview itself once it boots, but those don't help
290
+ * when the webview never boots in the first place.
291
+ *
292
+ * The actual state machine lives in {@link createWebViewPhaseTimerCore},
293
+ * which is also reused by the embedded native WebView path. This hook
294
+ * only layers the React-specific bits (ref-based core lifetime tied to
295
+ * `useEffect`, URL-derived `hadClearState` / `retryCount`).
296
+ */
297
+ const useWebViewPhaseTimers = ({
298
+ core,
299
+ loadingTimeout,
300
+ recoveryTimeout,
301
+ webViewUrl
302
+ }) => {
303
+ const phaseCoreRef = react.useRef(null);
304
+ const webViewUrlRef = react.useRef(webViewUrl);
305
+ webViewUrlRef.current = webViewUrl;
306
+ /**
307
+ * Owning the message-transport subscription in `useEffect` matches the
308
+ * lifetime semantics any other React subscription would have, and lets
309
+ * us cleanly re-subscribe if `core` is ever swapped at runtime.
310
+ */
311
+ react.useEffect(() => {
312
+ const phaseCore = createWebViewPhaseTimerCore({
313
+ core
314
+ });
315
+ phaseCoreRef.current = phaseCore;
316
+ return () => {
317
+ phaseCore.dispose();
318
+ if (phaseCoreRef.current === phaseCore) {
319
+ phaseCoreRef.current = null;
320
+ }
321
+ };
322
+ }, [core]);
323
+ const recordEvent = react.useCallback(event => {
324
+ var _a;
325
+ (_a = phaseCoreRef.current) === null || _a === void 0 ? void 0 : _a.recordEvent(event);
326
+ }, []);
327
+ const getSuccessMeta = react.useCallback(() => {
328
+ var _a;
329
+ const url = webViewUrlRef.current;
330
+ return buildSuccessMeta({
331
+ core: (_a = phaseCoreRef.current) !== null && _a !== void 0 ? _a : EMPTY_CORE_SNAPSHOT,
332
+ hadClearState: hasClearStateInUrl(url),
333
+ loadingTimeoutMs: loadingTimeout,
334
+ recoveryTimeoutMs: recoveryTimeout,
335
+ retryCount: readRetryCount(url),
336
+ webviewUrl: url.toString()
337
+ });
338
+ }, [loadingTimeout, recoveryTimeout]);
339
+ const getMeta = react.useCallback(({
340
+ phase
341
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
342
+ phase
343
+ }), [getSuccessMeta]);
344
+ return {
345
+ getMeta,
346
+ getSuccessMeta,
347
+ recordEvent
348
+ };
349
+ };
350
+
351
+ /******************************************************************************
352
+ Copyright (c) Microsoft Corporation.
353
+
354
+ Permission to use, copy, modify, and/or distribute this software for any
355
+ purpose with or without fee is hereby granted.
356
+
357
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
358
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
359
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
360
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
361
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
362
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
363
+ PERFORMANCE OF THIS SOFTWARE.
364
+ ***************************************************************************** */
365
+
366
+ function __awaiter(thisArg, _arguments, P, generator) {
367
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
368
+ return new (P || (P = Promise))(function (resolve, reject) {
369
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
370
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
371
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
372
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
373
+ });
374
+ }
375
+
376
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
377
+ var e = new Error(message);
378
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
379
+ };
380
+
381
+ const SUCCESS_LOG_KEY = 'webview.load_succeeded';
382
+ /**
383
+ * Shared emission pattern for the `webview.load_succeeded`
384
+ * instrumentation log. Both the RN `<WebView>` and the embedded
385
+ * WebView fire the same event with the same payload shape after
386
+ * checking the same cooldown gate — this helper centralises that
387
+ * sequence so the two call sites stay aligned and any future field we
388
+ * add to the success log lands in both places automatically.
389
+ */
390
+ const emitSuccessLog = _a => __awaiter(void 0, [_a], void 0, function* ({
391
+ cooldown,
392
+ core,
393
+ getMeta,
394
+ name
395
+ }) {
396
+ if (!(yield cooldown.shouldEmit())) return;
397
+ logger.instrument(`${name} loaded successfully`, Object.assign({
398
+ environmentId: core.environmentId,
399
+ hostSdkSessionId: core.hostSdkSessionId,
400
+ key: SUCCESS_LOG_KEY,
401
+ time: 0
402
+ }, getMeta()));
403
+ yield cooldown.recordEmitted();
404
+ });
405
+
406
+ const SUCCESS_LOG_COOLDOWN_MS = 60 * 60 * 1000;
407
+ const parseStoredTimestamp = raw => {
408
+ if (raw === null) return null;
409
+ const parsed = Number(raw);
410
+ return Number.isFinite(parsed) ? parsed : null;
411
+ };
412
+ /**
413
+ * Create a device-local cooldown gate for a `webview.load_succeeded`
414
+ * instrumentation log.
415
+ *
416
+ * Both `<WebView>` (RN bridge) and `<EmbeddedWebView>` (native bridge)
417
+ * emit the same success log; each instantiates its own cooldown with a
418
+ * dedicated storage key so emitting one log doesn't starve the other
419
+ * for an app that mounts both.
420
+ *
421
+ * - `shouldEmit` returns `true` when the cooldown has elapsed (or no
422
+ * timestamp has ever been stored) and `false` while inside the
423
+ * window. If secure-store throws on read we default to emitting —
424
+ * losing a diagnostic log is worse than the rare duplicate.
425
+ * - `recordEmitted` persists the current timestamp so subsequent calls
426
+ * within the window are suppressed. Write failures are swallowed
427
+ * (logged as a warning) so they don't break the host wrapper —
428
+ * worst case the next emit also goes through.
429
+ */
430
+ const createSuccessLogCooldown = ({
431
+ name,
432
+ storageKey
433
+ }) => ({
434
+ recordEmitted: (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (now = Date.now()) {
435
+ try {
436
+ yield expoSecureStore.setItemAsync(storageKey, now.toString());
437
+ } catch (error) {
438
+ logger.warn(`Failed to persist ${name} success-log cooldown timestamp`, {
439
+ error: String(error)
440
+ });
441
+ }
442
+ }),
443
+ shouldEmit: (...args_2) => __awaiter(void 0, [...args_2], void 0, function* (now = Date.now()) {
444
+ try {
445
+ const raw = yield expoSecureStore.getItemAsync(storageKey);
446
+ const lastEmittedAt = parseStoredTimestamp(raw);
447
+ if (lastEmittedAt === null) return true;
448
+ return now - lastEmittedAt >= SUCCESS_LOG_COOLDOWN_MS;
449
+ } catch (error) {
450
+ logger.warn(`Failed to read ${name} success-log cooldown timestamp; emitting anyway`, {
451
+ error: String(error)
452
+ });
453
+ return true;
454
+ }
455
+ })
456
+ });
457
+
458
+ const SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.webview.successLogLastEmittedAt';
459
+ /**
460
+ * Emit a `webview.load_succeeded` instrumentation log the first time
461
+ * the SDK signals it's ready, gated by a device-local 1h cooldown so a
462
+ * customer with many WebView mounts (or many app launches) doesn't spam
463
+ * the logs pipeline.
464
+ *
465
+ * The error path is unaffected: failures keep going through
466
+ * `logger.error(WebViewFailedToLoadError, meta)` with no cooldown.
467
+ *
468
+ * The hook intentionally fires only once per mount (`hasEmittedRef`):
469
+ * after a successful boot we don't expect another `sdkHasLoaded` for
470
+ * the same WebView lifetime, and emitting twice for the same load would
471
+ * be misleading.
472
+ *
473
+ * Cooldown gating + log emission are delegated to the shared
474
+ * `successLog` helpers so this hook and the embedded native WebView
475
+ * path stay in lockstep on payload shape and storage semantics; the
476
+ * dedicated storage key here keeps the RN-path cooldown independent
477
+ * from the embedded path's.
478
+ */
479
+ const useWebViewSuccessLog = ({
480
+ core,
481
+ getSuccessMeta
482
+ }) => {
483
+ const hasEmittedRef = react.useRef(false);
484
+ const getSuccessMetaRef = react.useRef(getSuccessMeta);
485
+ getSuccessMetaRef.current = getSuccessMeta;
486
+ react.useEffect(() => {
487
+ const cooldown = createSuccessLogCooldown({
488
+ name: 'webview',
489
+ storageKey: SUCCESS_LOG_COOLDOWN_STORAGE_KEY
490
+ });
491
+ const handler = message => __awaiter(void 0, void 0, void 0, function* () {
492
+ if (message.origin !== 'webview') return;
493
+ if (message.type !== webviewMessages.sdkHasLoadedEventName) return;
494
+ if (hasEmittedRef.current) return;
495
+ hasEmittedRef.current = true;
496
+ try {
497
+ yield emitSuccessLog({
498
+ cooldown,
499
+ core,
500
+ getMeta: getSuccessMetaRef.current,
501
+ name: 'Webview'
502
+ });
503
+ } catch (err) {
504
+ logger.warn('useWebViewSuccessLog.emitSuccessLog failed', {
505
+ error: String(err)
506
+ });
507
+ }
508
+ });
509
+ core.messageTransport.on(handler);
510
+ return () => {
511
+ core.messageTransport.off(handler);
512
+ };
513
+ }, [core]);
514
+ };
515
+
125
516
  const useWebViewVisibility = core => {
126
517
  const webViewVisibilityRequestChannelRef = react.useRef(messageTransport.createRequestChannel(core.messageTransport));
127
518
  const [visible, setVisible] = react.useState(false);
@@ -158,10 +549,6 @@ const styles = reactNative.StyleSheet.create({
158
549
  }
159
550
  });
160
551
 
161
- const CLEAR_STATE_PARAM = 'clear-state';
162
- const ENVIRONMENT_ID_PARAM = 'environmentId';
163
- const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
164
-
165
552
  const setClearStateToUrl = webViewUrl => {
166
553
  const newWebViewUrl = new URL(webViewUrl);
167
554
  newWebViewUrl.searchParams.set(CLEAR_STATE_PARAM, 'true');
@@ -169,8 +556,6 @@ const setClearStateToUrl = webViewUrl => {
169
556
  return newWebViewUrl;
170
557
  };
171
558
 
172
- const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
173
-
174
559
  const useWebViewRecoveryTimeout = ({
175
560
  core,
176
561
  webViewUrl,
@@ -323,15 +708,15 @@ const shouldAllowNavigation = (request, webViewUrl) => {
323
708
  return false;
324
709
  };
325
710
 
326
- const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
327
- const DEFAULT_LOADING_TIMEOUT_MS = 20000;
711
+ const DEFAULT_RECOVERY_TIMEOUT_MS$1 = 20000;
712
+ const DEFAULT_LOADING_TIMEOUT_MS$1 = 20000;
328
713
  const WebView = ({
329
714
  webviewUrl: initialWebViewUrl,
330
715
  core,
331
716
  webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
332
717
  disableRecovery: _disableRecovery = false,
333
- recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS,
334
- loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS
718
+ recoveryTimeout: _recoveryTimeout = DEFAULT_RECOVERY_TIMEOUT_MS$1,
719
+ loadingTimeout: _loadingTimeout = DEFAULT_LOADING_TIMEOUT_MS$1
335
720
  }) => {
336
721
  const webViewRef = react.useRef(null);
337
722
  const {
@@ -341,6 +726,20 @@ const WebView = ({
341
726
  const {
342
727
  onMessageHandler
343
728
  } = useMessageTransportWebViewBridge(core, webViewRef);
729
+ const {
730
+ recordEvent,
731
+ getMeta,
732
+ getSuccessMeta
733
+ } = useWebViewPhaseTimers({
734
+ core,
735
+ loadingTimeout: _loadingTimeout,
736
+ recoveryTimeout: _recoveryTimeout,
737
+ webViewUrl
738
+ });
739
+ useWebViewSuccessLog({
740
+ core,
741
+ getSuccessMeta
742
+ });
344
743
  const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
345
744
  /**
346
745
  * Block the message transport when the webview is unmounted
@@ -382,13 +781,23 @@ const WebView = ({
382
781
  return newWebViewUrl;
383
782
  });
384
783
  }, [core]);
385
- const blockAndReloadWebViewOsKill = react.useCallback(() => blockAndReloadWebView('os_kill'), [blockAndReloadWebView]);
784
+ const blockAndReloadWebViewOsKill = react.useCallback(() => {
785
+ recordEvent('os_kill');
786
+ blockAndReloadWebView('os_kill');
787
+ }, [blockAndReloadWebView, recordEvent]);
386
788
  react.useEffect(() => core.messageTransport.recoveryManager.onRecoveryRequested(() => blockAndReloadWebView('recovery')), [core, blockAndReloadWebView]);
387
- const setWebViewLoadError = react.useCallback(() => {
789
+ const setWebViewLoadError = react.useCallback(phase => {
388
790
  // Error was already thrown, do not throw again
389
791
  if (core.initialization.error instanceof WebViewFailedToLoadError) return;
390
- core.initialization.error = new WebViewFailedToLoadError();
391
- }, [core]);
792
+ core.initialization.error = new WebViewFailedToLoadError(getMeta({
793
+ phase
794
+ }));
795
+ }, [core, getMeta]);
796
+ /**
797
+ * Wraps `setWebViewLoadError` so the consumer doesn't need to know
798
+ * about the phase enum; each callsite below binds its own phase.
799
+ */
800
+ const setWebViewLoadErrorWithPhase = react.useCallback(phase => () => setWebViewLoadError(phase), [setWebViewLoadError]);
392
801
  /**
393
802
  * Reload the webview with a clean state when a timeout is reached
394
803
  * and the webview did not get to the loaded state yet
@@ -396,7 +805,7 @@ const WebView = ({
396
805
  const startRecoveryTimeout = useWebViewRecoveryTimeout({
397
806
  core,
398
807
  disableRecovery: _disableRecovery,
399
- onFailedToLoadAfterClearState: setWebViewLoadError,
808
+ onFailedToLoadAfterClearState: setWebViewLoadErrorWithPhase('after_clear_state'),
400
809
  recoveryTimeout: _recoveryTimeout,
401
810
  setWebViewUrl,
402
811
  webViewUrl
@@ -404,26 +813,40 @@ const WebView = ({
404
813
  const webViewLoadErrorCountRef = react.useRef(0);
405
814
  const onWebViewLoadError = react.useCallback(() => {
406
815
  webViewLoadErrorCountRef.current = webViewLoadErrorCountRef.current + 1;
816
+ recordEvent('native_error');
407
817
  /**
408
818
  * This is the first attempt to load the webview, do not throw an error
409
819
  * because the recovery system will attempt to reload the webview
410
820
  */
411
821
  if (webViewLoadErrorCountRef.current === 1) return;
412
- setWebViewLoadError();
413
- }, [setWebViewLoadError]);
822
+ setWebViewLoadError('native_error');
823
+ }, [recordEvent, setWebViewLoadError]);
414
824
  const {
415
- onLoad,
416
- onLoadStart
417
- } = useWebViewLoadingTimeout(_loadingTimeout, setWebViewLoadError);
825
+ onLoad: onLoadFromTimeout,
826
+ onLoadStart: onLoadStartFromTimeout
827
+ } = useWebViewLoadingTimeout(_loadingTimeout, setWebViewLoadErrorWithPhase('html_load'));
828
+ const onLoadStart = react.useCallback(() => {
829
+ recordEvent('load_start');
830
+ onLoadStartFromTimeout();
831
+ }, [onLoadStartFromTimeout, recordEvent]);
832
+ const onLoad = react.useCallback(() => {
833
+ recordEvent('load');
834
+ onLoadFromTimeout();
835
+ }, [onLoadFromTimeout, recordEvent]);
836
+ const onLoadEnd = react.useCallback(() => {
837
+ recordEvent('load_end');
838
+ startRecoveryTimeout();
839
+ }, [recordEvent, startRecoveryTimeout]);
840
+ const webViewUrlString = react.useMemo(() => webViewUrl.toString(), [webViewUrl]);
418
841
  return /*#__PURE__*/jsxRuntime.jsx(reactNativeWebview.WebView, {
419
842
  ref: webViewRef,
420
843
  source: {
421
- uri: webViewUrl.toString()
844
+ uri: webViewUrlString
422
845
  },
423
846
  containerStyle: containerStyles,
424
847
  style: styles['webview'],
425
848
  onMessage: onMessageHandler,
426
- onLoadEnd: () => startRecoveryTimeout(),
849
+ onLoadEnd: onLoadEnd,
427
850
  onLoadStart: onLoadStart,
428
851
  onLoad: onLoad,
429
852
  hideKeyboardAccessoryView: true,
@@ -436,7 +859,7 @@ const WebView = ({
436
859
  isTopFrame: request.isTopFrame,
437
860
  url: request.url
438
861
  }, webViewUrl)
439
- });
862
+ }, webViewUrlString);
440
863
  };
441
864
  const createWebView = internalProps => {
442
865
  const WebViewWrapper = props => /*#__PURE__*/jsxRuntime.jsx(WebView, _extends({}, internalProps, props));
@@ -475,6 +898,53 @@ const getEmbeddedWebView = () => {
475
898
  }
476
899
  };
477
900
 
901
+ /**
902
+ * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
903
+ * webview path. The embedded path is a singleton wired by
904
+ * `setupEmbeddedWebView` (not a React component), so we expose the same
905
+ * phase-timing surface as a plain factory.
906
+ *
907
+ * Both this factory and the hook delegate the actual state machine to
908
+ * {@link createWebViewPhaseTimerCore}; the only path-specific bits are:
909
+ *
910
+ * - `hadClearState` is always `false` (embedded path has no clear-state
911
+ * recovery model — that's exclusive to the React `<WebView>` path's
912
+ * loading-timeout recovery flow)
913
+ * - `retryCount` is always `0` (embedded path has no retry counter URL
914
+ * query param either)
915
+ */
916
+ const createEmbeddedWebViewPhaseTimers = ({
917
+ core,
918
+ loadingTimeoutMs,
919
+ recoveryTimeoutMs,
920
+ webViewUrl
921
+ }) => {
922
+ const phaseCore = createWebViewPhaseTimerCore({
923
+ core
924
+ });
925
+ const getSuccessMeta = () => buildSuccessMeta({
926
+ core: phaseCore,
927
+ hadClearState: false,
928
+ loadingTimeoutMs,
929
+ recoveryTimeoutMs,
930
+ retryCount: 0,
931
+ webviewUrl: webViewUrl.toString()
932
+ });
933
+ return {
934
+ dispose: phaseCore.dispose,
935
+ getMeta: ({
936
+ phase
937
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
938
+ phase
939
+ }),
940
+ getSuccessMeta,
941
+ recordEvent: phaseCore.recordEvent
942
+ };
943
+ };
944
+
945
+ const EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.embeddedWebView.successLogLastEmittedAt';
946
+ const DEFAULT_LOADING_TIMEOUT_MS = 20000;
947
+ const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
478
948
  // Wires the JS-side bridge to the native WKWebView singleton owned by Swift.
479
949
  // This is intentionally not a React component: the embedded webview lives
480
950
  // outside the React tree (in a dedicated UIWindow on iOS), so its setup runs
@@ -487,10 +957,52 @@ const getEmbeddedWebView = () => {
487
957
  const setupEmbeddedWebView = ({
488
958
  webviewUrl,
489
959
  core,
490
- webviewDebuggingEnabled: _webviewDebuggingEnabled = false
960
+ webviewDebuggingEnabled: _webviewDebuggingEnabled = false,
961
+ loadingTimeoutMs: _loadingTimeoutMs = DEFAULT_LOADING_TIMEOUT_MS,
962
+ recoveryTimeoutMs: _recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS
491
963
  }) => {
492
964
  const native = getEmbeddedWebView();
493
965
  const builtUrl = assignStartTimeToUrl(assignEnvironmentIdToUrl(webviewUrl, core.environmentId));
966
+ const phaseTimers = createEmbeddedWebViewPhaseTimers({
967
+ core,
968
+ loadingTimeoutMs: _loadingTimeoutMs,
969
+ recoveryTimeoutMs: _recoveryTimeoutMs,
970
+ webViewUrl: builtUrl
971
+ });
972
+ const successLogCooldown = createSuccessLogCooldown({
973
+ name: 'embedded webview',
974
+ storageKey: EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY
975
+ });
976
+ let loadingTimer = null;
977
+ let recoveryTimer = null;
978
+ let hasFailed = false;
979
+ let hasEmittedSuccessLog = false;
980
+ const clearLoadingTimer = () => {
981
+ if (loadingTimer !== null) {
982
+ clearTimeout(loadingTimer);
983
+ loadingTimer = null;
984
+ }
985
+ };
986
+ const clearRecoveryTimer = () => {
987
+ if (recoveryTimer !== null) {
988
+ clearTimeout(recoveryTimer);
989
+ recoveryTimer = null;
990
+ }
991
+ };
992
+ // Raise `WebViewFailedToLoadError` once per setup. Subsequent triggers
993
+ // (e.g. recovery timeout firing after html_load already failed) are
994
+ // suppressed so we don't spam the initialization-error setter with
995
+ // duplicate logs for the same load attempt.
996
+ const raiseFailure = (phase, extraMeta = {}) => {
997
+ if (hasFailed) return;
998
+ hasFailed = true;
999
+ clearLoadingTimer();
1000
+ clearRecoveryTimer();
1001
+ const meta = phaseTimers.getMeta({
1002
+ phase
1003
+ });
1004
+ core.initialization.error = new WebViewFailedToLoadError(Object.assign(Object.assign({}, meta), extraMeta));
1005
+ };
494
1006
  const visibilityChannel = messageTransport.createRequestChannel(core.messageTransport);
495
1007
  const removeVisibilityHandler = visibilityChannel.handle('setVisibility', visible => {
496
1008
  native.setVisible(visible).catch(err => {
@@ -504,6 +1016,30 @@ const setupEmbeddedWebView = ({
504
1016
  });
505
1017
  };
506
1018
  core.messageTransport.on(handleHostMessage);
1019
+ // Watch for the webview-side SDK ready signal so we can clear the
1020
+ // recovery timer and emit the success log. The phase-timer factory
1021
+ // also subscribes for its own bookkeeping — both subscriptions are
1022
+ // independent and idempotent. The factory subscribes first and so
1023
+ // sees `sdkReadyAt` before this handler runs, which means the meta
1024
+ // we emit below has the freshest `sdkReadyMs`. We only emit once
1025
+ // per setup; a second SDK-ready event for the same load wouldn't
1026
+ // represent a new successful boot.
1027
+ const handleSdkReady = message => {
1028
+ if (message.origin !== 'webview') return;
1029
+ if (message.type !== webviewMessages.sdkHasLoadedEventName) return;
1030
+ clearRecoveryTimer();
1031
+ if (hasEmittedSuccessLog || hasFailed) return;
1032
+ hasEmittedSuccessLog = true;
1033
+ emitSuccessLog({
1034
+ cooldown: successLogCooldown,
1035
+ core,
1036
+ getMeta: phaseTimers.getSuccessMeta,
1037
+ name: 'Embedded webview'
1038
+ }).catch(err => {
1039
+ logger.warn('EmbeddedWebView.emitSuccessLog failed', err);
1040
+ });
1041
+ };
1042
+ core.messageTransport.on(handleSdkReady);
507
1043
  const onMessageSub = native.addListener('onMessage', event => {
508
1044
  let parsed = null;
509
1045
  try {
@@ -526,7 +1062,32 @@ const setupEmbeddedWebView = ({
526
1062
  logger.warn('EmbeddedWebView.respondToShouldStartLoad failed', err);
527
1063
  });
528
1064
  });
1065
+ const onLoadStartSub = native.addListener('onLoadStart', () => {
1066
+ phaseTimers.recordEvent('load_start');
1067
+ // Re-arm both timers on every load_start. A retry from the native
1068
+ // side (e.g. a redirect, or a reload after a transient failure)
1069
+ // resets the html_load window cleanly without leaving a stale timer
1070
+ // around from the previous attempt.
1071
+ clearLoadingTimer();
1072
+ clearRecoveryTimer();
1073
+ loadingTimer = setTimeout(() => {
1074
+ raiseFailure('html_load');
1075
+ }, _loadingTimeoutMs);
1076
+ });
1077
+ const onLoadSub = native.addListener('onLoad', () => {
1078
+ phaseTimers.recordEvent('load');
1079
+ clearLoadingTimer();
1080
+ });
1081
+ const onLoadEndSub = native.addListener('onLoadEnd', () => {
1082
+ phaseTimers.recordEvent('load_end');
1083
+ clearLoadingTimer();
1084
+ clearRecoveryTimer();
1085
+ recoveryTimer = setTimeout(() => {
1086
+ raiseFailure('sdk_bootstrap');
1087
+ }, _recoveryTimeoutMs);
1088
+ });
529
1089
  const onLoadErrorSub = native.addListener('onLoadError', event => {
1090
+ phaseTimers.recordEvent('native_error');
530
1091
  logger.warn('EmbeddedWebView load error', event);
531
1092
  logger.instrument('Embedded webview load error', {
532
1093
  environmentId: core.environmentId,
@@ -540,7 +1101,13 @@ const setupEmbeddedWebView = ({
540
1101
  time: 0,
541
1102
  webviewUrl: builtUrl.toString()
542
1103
  });
543
- core.initialization.error = new WebViewFailedToLoadError();
1104
+ raiseFailure('embedded_native_error', {
1105
+ nativeErrorCode: event.code,
1106
+ nativeErrorDescription: event.description,
1107
+ nativeErrorDomain: event.domain,
1108
+ nativeErrorFailedUrl: event.url,
1109
+ nativeErrorIsProvisional: event.isProvisional
1110
+ });
544
1111
  });
545
1112
  native.setDebuggingEnabled(_webviewDebuggingEnabled).catch(err => {
546
1113
  logger.warn('EmbeddedWebView.setDebuggingEnabled failed', err);
@@ -549,44 +1116,21 @@ const setupEmbeddedWebView = ({
549
1116
  logger.warn('EmbeddedWebView.setUrl failed', err);
550
1117
  });
551
1118
  return () => {
1119
+ clearLoadingTimer();
1120
+ clearRecoveryTimer();
552
1121
  removeVisibilityHandler();
553
1122
  core.messageTransport.off(handleHostMessage);
1123
+ core.messageTransport.off(handleSdkReady);
554
1124
  onMessageSub.remove();
555
1125
  onShouldStartLoadSub.remove();
1126
+ onLoadStartSub.remove();
1127
+ onLoadSub.remove();
1128
+ onLoadEndSub.remove();
556
1129
  onLoadErrorSub.remove();
1130
+ phaseTimers.dispose();
557
1131
  };
558
1132
  };
559
1133
 
560
- /******************************************************************************
561
- Copyright (c) Microsoft Corporation.
562
-
563
- Permission to use, copy, modify, and/or distribute this software for any
564
- purpose with or without fee is hereby granted.
565
-
566
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
567
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
568
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
569
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
570
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
571
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
572
- PERFORMANCE OF THIS SOFTWARE.
573
- ***************************************************************************** */
574
-
575
- function __awaiter(thisArg, _arguments, P, generator) {
576
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
577
- return new (P || (P = Promise))(function (resolve, reject) {
578
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
579
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
580
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
581
- step((generator = generator.apply(thisArg, _arguments || [])).next());
582
- });
583
- }
584
-
585
- typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
586
- var e = new Error(message);
587
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
588
- };
589
-
590
1134
  const setupPasskeyHandler = core => {
591
1135
  const passkeyRequestChannel = messageTransport.createRequestChannel(core.messageTransport);
592
1136
  passkeyRequestChannel.handle('passkey_register', _a => __awaiter(void 0, [_a], void 0, function* ({