@dynamic-labs/react-native-extension 4.83.2-alpha.0 → 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.
package/index.cjs CHANGED
@@ -9,12 +9,12 @@ 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
11
  var webviewMessages = require('@dynamic-labs/webview-messages');
12
+ var expoSecureStore = require('expo-secure-store');
12
13
  var jsxRuntime = require('react/jsx-runtime');
13
14
  var expoModulesCore = require('expo-modules-core');
14
15
  var reactNativePasskey = require('react-native-passkey');
15
16
  var expoLinking = require('expo-linking');
16
17
  var expoWebBrowser = require('expo-web-browser');
17
- var expoSecureStore = require('expo-secure-store');
18
18
  var reactNativePasskeyStamper = require('@turnkey/react-native-passkey-stamper');
19
19
 
20
20
  function _interopNamespace(e) {
@@ -35,7 +35,7 @@ function _interopNamespace(e) {
35
35
  return Object.freeze(n);
36
36
  }
37
37
 
38
- var version = "4.83.2-alpha.0";
38
+ var version = "4.84.0";
39
39
 
40
40
  function _extends() {
41
41
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -129,22 +129,157 @@ const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
129
129
 
130
130
  const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
131
131
 
132
- const MANIFEST_MESSAGE_TYPE$1 = 'manifest';
133
- const RETRY_QUERY_PARAM = 'retry';
134
- const emptyPerAttemptState$1 = () => ({
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 = () => ({
135
142
  htmlLoadStartedAt: null,
136
143
  htmlLoadedAt: null,
137
144
  manifestReceivedAt: null,
138
145
  onLoadEndAt: null,
139
146
  sdkReadyAt: null
140
147
  });
141
- const diffMs$1 = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
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';
142
257
  const readRetryCount = webViewUrl => {
143
258
  const raw = webViewUrl.searchParams.get(RETRY_QUERY_PARAM);
144
259
  if (raw === null) return 0;
145
260
  const parsed = Number(raw);
146
261
  return Number.isFinite(parsed) ? parsed : 0;
147
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
+ };
148
283
  /**
149
284
  * Tracks how long each step of the WebView load took, so when we raise
150
285
  * `WebViewFailedToLoadError` we can attach the timings to the error log.
@@ -153,92 +288,231 @@ const readRetryCount = webViewUrl => {
153
288
  * timings (`webview.time_to_load_manifest`, `webview.time_to_sdk_ready`)
154
289
  * still come from the webview itself once it boots, but those don't help
155
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`).
156
296
  */
157
297
  const useWebViewPhaseTimers = ({
158
298
  core,
159
- webViewUrl,
160
299
  loadingTimeout,
161
- recoveryTimeout
300
+ recoveryTimeout,
301
+ webViewUrl
162
302
  }) => {
163
- const perAttemptRef = react.useRef(emptyPerAttemptState$1());
164
- const nativeErrorCountRef = react.useRef(0);
165
- const osKillCountRef = react.useRef(0);
303
+ const phaseCoreRef = react.useRef(null);
166
304
  const webViewUrlRef = react.useRef(webViewUrl);
167
305
  webViewUrlRef.current = webViewUrl;
168
306
  /**
169
- * Listen on the message transport for the two webview-originated
170
- * messages that mark "JS bundle alive" and "SDK ready".
171
- *
172
- * We only record the first occurrence per attempt so that a healthy
173
- * reload doesn't overwrite the timing from the attempt that ultimately
174
- * failed.
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.
175
310
  */
176
311
  react.useEffect(() => {
177
- const handler = message => {
178
- if (message.origin !== 'webview') return;
179
- if (message.type === MANIFEST_MESSAGE_TYPE$1 && perAttemptRef.current.manifestReceivedAt === null) {
180
- perAttemptRef.current.manifestReceivedAt = Date.now();
181
- }
182
- if (message.type === webviewMessages.sdkHasLoadedEventName && perAttemptRef.current.sdkReadyAt === null) {
183
- perAttemptRef.current.sdkReadyAt = Date.now();
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;
184
320
  }
185
321
  };
186
- core.messageTransport.on(handler);
187
- return () => core.messageTransport.off(handler);
188
322
  }, [core]);
189
323
  const recordEvent = react.useCallback(event => {
190
- const now = Date.now();
191
- switch (event) {
192
- case 'load_start':
193
- perAttemptRef.current = emptyPerAttemptState$1();
194
- perAttemptRef.current.htmlLoadStartedAt = now;
195
- break;
196
- case 'load':
197
- perAttemptRef.current.htmlLoadedAt = now;
198
- break;
199
- case 'load_end':
200
- perAttemptRef.current.onLoadEndAt = now;
201
- break;
202
- case 'native_error':
203
- nativeErrorCountRef.current += 1;
204
- break;
205
- case 'os_kill':
206
- osKillCountRef.current += 1;
207
- break;
208
- }
324
+ var _a;
325
+ (_a = phaseCoreRef.current) === null || _a === void 0 ? void 0 : _a.recordEvent(event);
209
326
  }, []);
210
- const getMeta = react.useCallback(({
211
- phase
212
- }) => {
327
+ const getSuccessMeta = react.useCallback(() => {
328
+ var _a;
213
329
  const url = webViewUrlRef.current;
214
- const {
215
- htmlLoadStartedAt,
216
- htmlLoadedAt,
217
- onLoadEndAt,
218
- manifestReceivedAt,
219
- sdkReadyAt
220
- } = perAttemptRef.current;
221
- return {
330
+ return buildSuccessMeta({
331
+ core: (_a = phaseCoreRef.current) !== null && _a !== void 0 ? _a : EMPTY_CORE_SNAPSHOT,
222
332
  hadClearState: hasClearStateInUrl(url),
223
- htmlLoadMs: diffMs$1(htmlLoadedAt, htmlLoadStartedAt),
224
333
  loadingTimeoutMs: loadingTimeout,
225
- manifestReceivedMs: diffMs$1(manifestReceivedAt, onLoadEndAt),
226
- nativeErrorCount: nativeErrorCountRef.current,
227
- onLoadToOnLoadEndMs: diffMs$1(onLoadEndAt, htmlLoadedAt),
228
- osKillCount: osKillCountRef.current,
229
- phase,
230
334
  recoveryTimeoutMs: recoveryTimeout,
231
335
  retryCount: readRetryCount(url),
232
- sdkReadyMs: diffMs$1(sdkReadyAt, onLoadEndAt),
233
336
  webviewUrl: url.toString()
234
- };
337
+ });
235
338
  }, [loadingTimeout, recoveryTimeout]);
339
+ const getMeta = react.useCallback(({
340
+ phase
341
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
342
+ phase
343
+ }), [getSuccessMeta]);
236
344
  return {
237
345
  getMeta,
346
+ getSuccessMeta,
238
347
  recordEvent
239
348
  };
240
349
  };
241
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
+
242
516
  const useWebViewVisibility = core => {
243
517
  const webViewVisibilityRequestChannelRef = react.useRef(messageTransport.createRequestChannel(core.messageTransport));
244
518
  const [visible, setVisible] = react.useState(false);
@@ -454,13 +728,18 @@ const WebView = ({
454
728
  } = useMessageTransportWebViewBridge(core, webViewRef);
455
729
  const {
456
730
  recordEvent,
457
- getMeta
731
+ getMeta,
732
+ getSuccessMeta
458
733
  } = useWebViewPhaseTimers({
459
734
  core,
460
735
  loadingTimeout: _loadingTimeout,
461
736
  recoveryTimeout: _recoveryTimeout,
462
737
  webViewUrl
463
738
  });
739
+ useWebViewSuccessLog({
740
+ core,
741
+ getSuccessMeta
742
+ });
464
743
  const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
465
744
  /**
466
745
  * Block the message transport when the webview is unmounted
@@ -558,10 +837,11 @@ const WebView = ({
558
837
  recordEvent('load_end');
559
838
  startRecoveryTimeout();
560
839
  }, [recordEvent, startRecoveryTimeout]);
840
+ const webViewUrlString = react.useMemo(() => webViewUrl.toString(), [webViewUrl]);
561
841
  return /*#__PURE__*/jsxRuntime.jsx(reactNativeWebview.WebView, {
562
842
  ref: webViewRef,
563
843
  source: {
564
- uri: webViewUrl.toString()
844
+ uri: webViewUrlString
565
845
  },
566
846
  containerStyle: containerStyles,
567
847
  style: styles['webview'],
@@ -579,7 +859,7 @@ const WebView = ({
579
859
  isTopFrame: request.isTopFrame,
580
860
  url: request.url
581
861
  }, webViewUrl)
582
- });
862
+ }, webViewUrlString);
583
863
  };
584
864
  const createWebView = internalProps => {
585
865
  const WebViewWrapper = props => /*#__PURE__*/jsxRuntime.jsx(WebView, _extends({}, internalProps, props));
@@ -618,103 +898,51 @@ const getEmbeddedWebView = () => {
618
898
  }
619
899
  };
620
900
 
621
- const MANIFEST_MESSAGE_TYPE = 'manifest';
622
- const emptyPerAttemptState = () => ({
623
- htmlLoadStartedAt: null,
624
- htmlLoadedAt: null,
625
- manifestReceivedAt: null,
626
- onLoadEndAt: null,
627
- sdkReadyAt: null
628
- });
629
- const diffMs = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
630
901
  /**
631
902
  * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
632
- * webview path. The embedded path is a singleton wired by `setupEmbeddedWebView`
633
- * (not a React component), so we expose the same phase-timing surface as a
634
- * plain factory.
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.
635
906
  *
636
- * The hook and the factory are intentionally separate: the embedded path does
637
- * not have a retry or clear-state model, so `retryCount` is always `0` and
638
- * `hadClearState` is always `false` in the meta produced here. Sharing one
639
- * implementation would require either pulling React into a hot non-React path
640
- * or threading a stateless adapter through both consumers \u2014 the duplication
641
- * is small enough that keeping them separate stays cleaner.
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)
642
915
  */
643
916
  const createEmbeddedWebViewPhaseTimers = ({
644
917
  core,
645
- webViewUrl,
646
918
  loadingTimeoutMs,
647
- recoveryTimeoutMs
919
+ recoveryTimeoutMs,
920
+ webViewUrl
648
921
  }) => {
649
- let perAttempt = emptyPerAttemptState();
650
- let nativeErrorCount = 0;
651
- let osKillCount = 0;
652
- const handleMessage = message => {
653
- if (message.origin !== 'webview') return;
654
- if (message.type === MANIFEST_MESSAGE_TYPE && perAttempt.manifestReceivedAt === null) {
655
- perAttempt.manifestReceivedAt = Date.now();
656
- }
657
- if (message.type === webviewMessages.sdkHasLoadedEventName && perAttempt.sdkReadyAt === null) {
658
- perAttempt.sdkReadyAt = Date.now();
659
- }
660
- };
661
- core.messageTransport.on(handleMessage);
662
- const recordEvent = event => {
663
- const now = Date.now();
664
- switch (event) {
665
- case 'load_start':
666
- perAttempt = emptyPerAttemptState();
667
- perAttempt.htmlLoadStartedAt = now;
668
- break;
669
- case 'load':
670
- perAttempt.htmlLoadedAt = now;
671
- break;
672
- case 'load_end':
673
- perAttempt.onLoadEndAt = now;
674
- break;
675
- case 'native_error':
676
- nativeErrorCount += 1;
677
- break;
678
- case 'os_kill':
679
- osKillCount += 1;
680
- break;
681
- }
682
- };
683
- const getMeta = ({
684
- phase
685
- }) => {
686
- const {
687
- htmlLoadStartedAt,
688
- htmlLoadedAt,
689
- onLoadEndAt,
690
- manifestReceivedAt,
691
- sdkReadyAt
692
- } = perAttempt;
693
- return {
694
- hadClearState: false,
695
- htmlLoadMs: diffMs(htmlLoadedAt, htmlLoadStartedAt),
696
- loadingTimeoutMs,
697
- manifestReceivedMs: diffMs(manifestReceivedAt, onLoadEndAt),
698
- nativeErrorCount,
699
- onLoadToOnLoadEndMs: diffMs(onLoadEndAt, htmlLoadedAt),
700
- osKillCount,
701
- phase,
702
- recoveryTimeoutMs,
703
- retryCount: 0,
704
- sdkReadyMs: diffMs(sdkReadyAt, onLoadEndAt),
705
- webviewUrl: webViewUrl.toString()
706
- };
707
- };
708
- const dispose = () => {
709
- core.messageTransport.off(handleMessage);
710
- };
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
+ });
711
933
  return {
712
- dispose,
713
- getMeta,
714
- recordEvent
934
+ dispose: phaseCore.dispose,
935
+ getMeta: ({
936
+ phase
937
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
938
+ phase
939
+ }),
940
+ getSuccessMeta,
941
+ recordEvent: phaseCore.recordEvent
715
942
  };
716
943
  };
717
944
 
945
+ const EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.embeddedWebView.successLogLastEmittedAt';
718
946
  const DEFAULT_LOADING_TIMEOUT_MS = 20000;
719
947
  const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
720
948
  // Wires the JS-side bridge to the native WKWebView singleton owned by Swift.
@@ -741,9 +969,14 @@ const setupEmbeddedWebView = ({
741
969
  recoveryTimeoutMs: _recoveryTimeoutMs,
742
970
  webViewUrl: builtUrl
743
971
  });
972
+ const successLogCooldown = createSuccessLogCooldown({
973
+ name: 'embedded webview',
974
+ storageKey: EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY
975
+ });
744
976
  let loadingTimer = null;
745
977
  let recoveryTimer = null;
746
978
  let hasFailed = false;
979
+ let hasEmittedSuccessLog = false;
747
980
  const clearLoadingTimer = () => {
748
981
  if (loadingTimer !== null) {
749
982
  clearTimeout(loadingTimer);
@@ -784,12 +1017,27 @@ const setupEmbeddedWebView = ({
784
1017
  };
785
1018
  core.messageTransport.on(handleHostMessage);
786
1019
  // Watch for the webview-side SDK ready signal so we can clear the
787
- // recovery timer. The phase-timer factory also subscribes for its own
788
- // bookkeeping — both subscriptions are independent and idempotent.
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.
789
1027
  const handleSdkReady = message => {
790
1028
  if (message.origin !== 'webview') return;
791
1029
  if (message.type !== webviewMessages.sdkHasLoadedEventName) return;
792
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
+ });
793
1041
  };
794
1042
  core.messageTransport.on(handleSdkReady);
795
1043
  const onMessageSub = native.addListener('onMessage', event => {
@@ -883,36 +1131,6 @@ const setupEmbeddedWebView = ({
883
1131
  };
884
1132
  };
885
1133
 
886
- /******************************************************************************
887
- Copyright (c) Microsoft Corporation.
888
-
889
- Permission to use, copy, modify, and/or distribute this software for any
890
- purpose with or without fee is hereby granted.
891
-
892
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
893
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
894
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
895
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
896
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
897
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
898
- PERFORMANCE OF THIS SOFTWARE.
899
- ***************************************************************************** */
900
-
901
- function __awaiter(thisArg, _arguments, P, generator) {
902
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
903
- return new (P || (P = Promise))(function (resolve, reject) {
904
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
905
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
906
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
907
- step((generator = generator.apply(thisArg, _arguments || [])).next());
908
- });
909
- }
910
-
911
- typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
912
- var e = new Error(message);
913
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
914
- };
915
-
916
1134
  const setupPasskeyHandler = core => {
917
1135
  const passkeyRequestChannel = messageTransport.createRequestChannel(core.messageTransport);
918
1136
  passkeyRequestChannel.handle('passkey_register', _a => __awaiter(void 0, [_a], void 0, function* ({