@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.js CHANGED
@@ -1,19 +1,19 @@
1
1
  import { assertPackageVersion } from '@dynamic-labs/assert-package-version';
2
2
  import { StyleSheet, Platform } from 'react-native';
3
- import { useEffect, useRef, useCallback, useState } from 'react';
3
+ import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
4
4
  import { WebView as WebView$1 } from 'react-native-webview';
5
5
  import { Logger } from '@dynamic-labs/logger';
6
6
  import { messageTransportDataJsonReviver, parseMessageTransportData, messageTransportDataJsonReplacer, createRequestChannel } from '@dynamic-labs/message-transport';
7
7
  import { sdkHasLoadedEventName } from '@dynamic-labs/webview-messages';
8
+ import { setItemAsync, getItemAsync, deleteItemAsync } from 'expo-secure-store';
8
9
  import { jsx } from 'react/jsx-runtime';
9
10
  import { requireNativeModule } from 'expo-modules-core';
10
11
  import { Passkey } from 'react-native-passkey';
11
12
  import { createURL, getInitialURL, addEventListener, openURL } from 'expo-linking';
12
13
  import { openAuthSessionAsync } from 'expo-web-browser';
13
- import { getItemAsync, deleteItemAsync, setItemAsync } from 'expo-secure-store';
14
14
  import { createPasskey, PasskeyStamper } from '@turnkey/react-native-passkey-stamper';
15
15
 
16
- var version = "4.83.2-alpha.0";
16
+ var version = "4.84.0";
17
17
 
18
18
  function _extends() {
19
19
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -107,22 +107,157 @@ const WEBVIEW_START_TIME_PARAM = 'webviewStartTime';
107
107
 
108
108
  const hasClearStateInUrl = webViewUrl => Boolean(webViewUrl.searchParams.get(CLEAR_STATE_PARAM));
109
109
 
110
- const MANIFEST_MESSAGE_TYPE$1 = 'manifest';
111
- const RETRY_QUERY_PARAM = 'retry';
112
- const emptyPerAttemptState$1 = () => ({
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 = () => ({
113
120
  htmlLoadStartedAt: null,
114
121
  htmlLoadedAt: null,
115
122
  manifestReceivedAt: null,
116
123
  onLoadEndAt: null,
117
124
  sdkReadyAt: null
118
125
  });
119
- const diffMs$1 = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
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';
120
235
  const readRetryCount = webViewUrl => {
121
236
  const raw = webViewUrl.searchParams.get(RETRY_QUERY_PARAM);
122
237
  if (raw === null) return 0;
123
238
  const parsed = Number(raw);
124
239
  return Number.isFinite(parsed) ? parsed : 0;
125
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
+ };
126
261
  /**
127
262
  * Tracks how long each step of the WebView load took, so when we raise
128
263
  * `WebViewFailedToLoadError` we can attach the timings to the error log.
@@ -131,92 +266,231 @@ const readRetryCount = webViewUrl => {
131
266
  * timings (`webview.time_to_load_manifest`, `webview.time_to_sdk_ready`)
132
267
  * still come from the webview itself once it boots, but those don't help
133
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`).
134
274
  */
135
275
  const useWebViewPhaseTimers = ({
136
276
  core,
137
- webViewUrl,
138
277
  loadingTimeout,
139
- recoveryTimeout
278
+ recoveryTimeout,
279
+ webViewUrl
140
280
  }) => {
141
- const perAttemptRef = useRef(emptyPerAttemptState$1());
142
- const nativeErrorCountRef = useRef(0);
143
- const osKillCountRef = useRef(0);
281
+ const phaseCoreRef = useRef(null);
144
282
  const webViewUrlRef = useRef(webViewUrl);
145
283
  webViewUrlRef.current = webViewUrl;
146
284
  /**
147
- * Listen on the message transport for the two webview-originated
148
- * messages that mark "JS bundle alive" and "SDK ready".
149
- *
150
- * We only record the first occurrence per attempt so that a healthy
151
- * reload doesn't overwrite the timing from the attempt that ultimately
152
- * failed.
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.
153
288
  */
154
289
  useEffect(() => {
155
- const handler = message => {
156
- if (message.origin !== 'webview') return;
157
- if (message.type === MANIFEST_MESSAGE_TYPE$1 && perAttemptRef.current.manifestReceivedAt === null) {
158
- perAttemptRef.current.manifestReceivedAt = Date.now();
159
- }
160
- if (message.type === sdkHasLoadedEventName && perAttemptRef.current.sdkReadyAt === null) {
161
- perAttemptRef.current.sdkReadyAt = Date.now();
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;
162
298
  }
163
299
  };
164
- core.messageTransport.on(handler);
165
- return () => core.messageTransport.off(handler);
166
300
  }, [core]);
167
301
  const recordEvent = useCallback(event => {
168
- const now = Date.now();
169
- switch (event) {
170
- case 'load_start':
171
- perAttemptRef.current = emptyPerAttemptState$1();
172
- perAttemptRef.current.htmlLoadStartedAt = now;
173
- break;
174
- case 'load':
175
- perAttemptRef.current.htmlLoadedAt = now;
176
- break;
177
- case 'load_end':
178
- perAttemptRef.current.onLoadEndAt = now;
179
- break;
180
- case 'native_error':
181
- nativeErrorCountRef.current += 1;
182
- break;
183
- case 'os_kill':
184
- osKillCountRef.current += 1;
185
- break;
186
- }
302
+ var _a;
303
+ (_a = phaseCoreRef.current) === null || _a === void 0 ? void 0 : _a.recordEvent(event);
187
304
  }, []);
188
- const getMeta = useCallback(({
189
- phase
190
- }) => {
305
+ const getSuccessMeta = useCallback(() => {
306
+ var _a;
191
307
  const url = webViewUrlRef.current;
192
- const {
193
- htmlLoadStartedAt,
194
- htmlLoadedAt,
195
- onLoadEndAt,
196
- manifestReceivedAt,
197
- sdkReadyAt
198
- } = perAttemptRef.current;
199
- return {
308
+ return buildSuccessMeta({
309
+ core: (_a = phaseCoreRef.current) !== null && _a !== void 0 ? _a : EMPTY_CORE_SNAPSHOT,
200
310
  hadClearState: hasClearStateInUrl(url),
201
- htmlLoadMs: diffMs$1(htmlLoadedAt, htmlLoadStartedAt),
202
311
  loadingTimeoutMs: loadingTimeout,
203
- manifestReceivedMs: diffMs$1(manifestReceivedAt, onLoadEndAt),
204
- nativeErrorCount: nativeErrorCountRef.current,
205
- onLoadToOnLoadEndMs: diffMs$1(onLoadEndAt, htmlLoadedAt),
206
- osKillCount: osKillCountRef.current,
207
- phase,
208
312
  recoveryTimeoutMs: recoveryTimeout,
209
313
  retryCount: readRetryCount(url),
210
- sdkReadyMs: diffMs$1(sdkReadyAt, onLoadEndAt),
211
314
  webviewUrl: url.toString()
212
- };
315
+ });
213
316
  }, [loadingTimeout, recoveryTimeout]);
317
+ const getMeta = useCallback(({
318
+ phase
319
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
320
+ phase
321
+ }), [getSuccessMeta]);
214
322
  return {
215
323
  getMeta,
324
+ getSuccessMeta,
216
325
  recordEvent
217
326
  };
218
327
  };
219
328
 
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();
382
+ });
383
+
384
+ const SUCCESS_LOG_COOLDOWN_MS = 60 * 60 * 1000;
385
+ const parseStoredTimestamp = raw => {
386
+ if (raw === null) return null;
387
+ const parsed = Number(raw);
388
+ return Number.isFinite(parsed) ? parsed : null;
389
+ };
390
+ /**
391
+ * Create a device-local cooldown gate for a `webview.load_succeeded`
392
+ * instrumentation log.
393
+ *
394
+ * Both `<WebView>` (RN bridge) and `<EmbeddedWebView>` (native bridge)
395
+ * emit the same success log; each instantiates its own cooldown with a
396
+ * dedicated storage key so emitting one log doesn't starve the other
397
+ * for an app that mounts both.
398
+ *
399
+ * - `shouldEmit` returns `true` when the cooldown has elapsed (or no
400
+ * timestamp has ever been stored) and `false` while inside the
401
+ * window. If secure-store throws on read we default to emitting —
402
+ * losing a diagnostic log is worse than the rare duplicate.
403
+ * - `recordEmitted` persists the current timestamp so subsequent calls
404
+ * within the window are suppressed. Write failures are swallowed
405
+ * (logged as a warning) so they don't break the host wrapper —
406
+ * worst case the next emit also goes through.
407
+ */
408
+ const createSuccessLogCooldown = ({
409
+ name,
410
+ storageKey
411
+ }) => ({
412
+ recordEmitted: (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (now = Date.now()) {
413
+ try {
414
+ yield setItemAsync(storageKey, now.toString());
415
+ } catch (error) {
416
+ logger.warn(`Failed to persist ${name} success-log cooldown timestamp`, {
417
+ error: String(error)
418
+ });
419
+ }
420
+ }),
421
+ shouldEmit: (...args_2) => __awaiter(void 0, [...args_2], void 0, function* (now = Date.now()) {
422
+ try {
423
+ const raw = yield getItemAsync(storageKey);
424
+ const lastEmittedAt = parseStoredTimestamp(raw);
425
+ if (lastEmittedAt === null) return true;
426
+ return now - lastEmittedAt >= SUCCESS_LOG_COOLDOWN_MS;
427
+ } catch (error) {
428
+ logger.warn(`Failed to read ${name} success-log cooldown timestamp; emitting anyway`, {
429
+ error: String(error)
430
+ });
431
+ return true;
432
+ }
433
+ })
434
+ });
435
+
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
+
220
494
  const useWebViewVisibility = core => {
221
495
  const webViewVisibilityRequestChannelRef = useRef(createRequestChannel(core.messageTransport));
222
496
  const [visible, setVisible] = useState(false);
@@ -432,13 +706,18 @@ const WebView = ({
432
706
  } = useMessageTransportWebViewBridge(core, webViewRef);
433
707
  const {
434
708
  recordEvent,
435
- getMeta
709
+ getMeta,
710
+ getSuccessMeta
436
711
  } = useWebViewPhaseTimers({
437
712
  core,
438
713
  loadingTimeout: _loadingTimeout,
439
714
  recoveryTimeout: _recoveryTimeout,
440
715
  webViewUrl
441
716
  });
717
+ useWebViewSuccessLog({
718
+ core,
719
+ getSuccessMeta
720
+ });
442
721
  const containerStyles = [styles['container'], visible ? styles.show : styles.hide];
443
722
  /**
444
723
  * Block the message transport when the webview is unmounted
@@ -536,10 +815,11 @@ const WebView = ({
536
815
  recordEvent('load_end');
537
816
  startRecoveryTimeout();
538
817
  }, [recordEvent, startRecoveryTimeout]);
818
+ const webViewUrlString = useMemo(() => webViewUrl.toString(), [webViewUrl]);
539
819
  return /*#__PURE__*/jsx(WebView$1, {
540
820
  ref: webViewRef,
541
821
  source: {
542
- uri: webViewUrl.toString()
822
+ uri: webViewUrlString
543
823
  },
544
824
  containerStyle: containerStyles,
545
825
  style: styles['webview'],
@@ -557,7 +837,7 @@ const WebView = ({
557
837
  isTopFrame: request.isTopFrame,
558
838
  url: request.url
559
839
  }, webViewUrl)
560
- });
840
+ }, webViewUrlString);
561
841
  };
562
842
  const createWebView = internalProps => {
563
843
  const WebViewWrapper = props => /*#__PURE__*/jsx(WebView, _extends({}, internalProps, props));
@@ -596,103 +876,51 @@ const getEmbeddedWebView = () => {
596
876
  }
597
877
  };
598
878
 
599
- const MANIFEST_MESSAGE_TYPE = 'manifest';
600
- const emptyPerAttemptState = () => ({
601
- htmlLoadStartedAt: null,
602
- htmlLoadedAt: null,
603
- manifestReceivedAt: null,
604
- onLoadEndAt: null,
605
- sdkReadyAt: null
606
- });
607
- const diffMs = (later, earlier) => later !== null && earlier !== null ? later - earlier : null;
608
879
  /**
609
880
  * Non-React equivalent of `useWebViewPhaseTimers` for the embedded native
610
- * webview path. The embedded path is a singleton wired by `setupEmbeddedWebView`
611
- * (not a React component), so we expose the same phase-timing surface as a
612
- * plain factory.
881
+ * webview path. The embedded path is a singleton wired by
882
+ * `setupEmbeddedWebView` (not a React component), so we expose the same
883
+ * phase-timing surface as a plain factory.
613
884
  *
614
- * The hook and the factory are intentionally separate: the embedded path does
615
- * not have a retry or clear-state model, so `retryCount` is always `0` and
616
- * `hadClearState` is always `false` in the meta produced here. Sharing one
617
- * implementation would require either pulling React into a hot non-React path
618
- * or threading a stateless adapter through both consumers \u2014 the duplication
619
- * is small enough that keeping them separate stays cleaner.
885
+ * Both this factory and the hook delegate the actual state machine to
886
+ * {@link createWebViewPhaseTimerCore}; the only path-specific bits are:
887
+ *
888
+ * - `hadClearState` is always `false` (embedded path has no clear-state
889
+ * recovery model that's exclusive to the React `<WebView>` path's
890
+ * loading-timeout recovery flow)
891
+ * - `retryCount` is always `0` (embedded path has no retry counter URL
892
+ * query param either)
620
893
  */
621
894
  const createEmbeddedWebViewPhaseTimers = ({
622
895
  core,
623
- webViewUrl,
624
896
  loadingTimeoutMs,
625
- recoveryTimeoutMs
897
+ recoveryTimeoutMs,
898
+ webViewUrl
626
899
  }) => {
627
- let perAttempt = emptyPerAttemptState();
628
- let nativeErrorCount = 0;
629
- let osKillCount = 0;
630
- const handleMessage = message => {
631
- if (message.origin !== 'webview') return;
632
- if (message.type === MANIFEST_MESSAGE_TYPE && perAttempt.manifestReceivedAt === null) {
633
- perAttempt.manifestReceivedAt = Date.now();
634
- }
635
- if (message.type === sdkHasLoadedEventName && perAttempt.sdkReadyAt === null) {
636
- perAttempt.sdkReadyAt = Date.now();
637
- }
638
- };
639
- core.messageTransport.on(handleMessage);
640
- const recordEvent = event => {
641
- const now = Date.now();
642
- switch (event) {
643
- case 'load_start':
644
- perAttempt = emptyPerAttemptState();
645
- perAttempt.htmlLoadStartedAt = now;
646
- break;
647
- case 'load':
648
- perAttempt.htmlLoadedAt = now;
649
- break;
650
- case 'load_end':
651
- perAttempt.onLoadEndAt = now;
652
- break;
653
- case 'native_error':
654
- nativeErrorCount += 1;
655
- break;
656
- case 'os_kill':
657
- osKillCount += 1;
658
- break;
659
- }
660
- };
661
- const getMeta = ({
662
- phase
663
- }) => {
664
- const {
665
- htmlLoadStartedAt,
666
- htmlLoadedAt,
667
- onLoadEndAt,
668
- manifestReceivedAt,
669
- sdkReadyAt
670
- } = perAttempt;
671
- return {
672
- hadClearState: false,
673
- htmlLoadMs: diffMs(htmlLoadedAt, htmlLoadStartedAt),
674
- loadingTimeoutMs,
675
- manifestReceivedMs: diffMs(manifestReceivedAt, onLoadEndAt),
676
- nativeErrorCount,
677
- onLoadToOnLoadEndMs: diffMs(onLoadEndAt, htmlLoadedAt),
678
- osKillCount,
679
- phase,
680
- recoveryTimeoutMs,
681
- retryCount: 0,
682
- sdkReadyMs: diffMs(sdkReadyAt, onLoadEndAt),
683
- webviewUrl: webViewUrl.toString()
684
- };
685
- };
686
- const dispose = () => {
687
- core.messageTransport.off(handleMessage);
688
- };
900
+ const phaseCore = createWebViewPhaseTimerCore({
901
+ core
902
+ });
903
+ const getSuccessMeta = () => buildSuccessMeta({
904
+ core: phaseCore,
905
+ hadClearState: false,
906
+ loadingTimeoutMs,
907
+ recoveryTimeoutMs,
908
+ retryCount: 0,
909
+ webviewUrl: webViewUrl.toString()
910
+ });
689
911
  return {
690
- dispose,
691
- getMeta,
692
- recordEvent
912
+ dispose: phaseCore.dispose,
913
+ getMeta: ({
914
+ phase
915
+ }) => Object.assign(Object.assign({}, getSuccessMeta()), {
916
+ phase
917
+ }),
918
+ getSuccessMeta,
919
+ recordEvent: phaseCore.recordEvent
693
920
  };
694
921
  };
695
922
 
923
+ const EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY = 'dynamic.embeddedWebView.successLogLastEmittedAt';
696
924
  const DEFAULT_LOADING_TIMEOUT_MS = 20000;
697
925
  const DEFAULT_RECOVERY_TIMEOUT_MS = 20000;
698
926
  // Wires the JS-side bridge to the native WKWebView singleton owned by Swift.
@@ -719,9 +947,14 @@ const setupEmbeddedWebView = ({
719
947
  recoveryTimeoutMs: _recoveryTimeoutMs,
720
948
  webViewUrl: builtUrl
721
949
  });
950
+ const successLogCooldown = createSuccessLogCooldown({
951
+ name: 'embedded webview',
952
+ storageKey: EMBEDDED_SUCCESS_LOG_COOLDOWN_STORAGE_KEY
953
+ });
722
954
  let loadingTimer = null;
723
955
  let recoveryTimer = null;
724
956
  let hasFailed = false;
957
+ let hasEmittedSuccessLog = false;
725
958
  const clearLoadingTimer = () => {
726
959
  if (loadingTimer !== null) {
727
960
  clearTimeout(loadingTimer);
@@ -762,12 +995,27 @@ const setupEmbeddedWebView = ({
762
995
  };
763
996
  core.messageTransport.on(handleHostMessage);
764
997
  // Watch for the webview-side SDK ready signal so we can clear the
765
- // recovery timer. The phase-timer factory also subscribes for its own
766
- // bookkeeping — both subscriptions are independent and idempotent.
998
+ // recovery timer and emit the success log. The phase-timer factory
999
+ // also subscribes for its own bookkeeping — both subscriptions are
1000
+ // independent and idempotent. The factory subscribes first and so
1001
+ // sees `sdkReadyAt` before this handler runs, which means the meta
1002
+ // we emit below has the freshest `sdkReadyMs`. We only emit once
1003
+ // per setup; a second SDK-ready event for the same load wouldn't
1004
+ // represent a new successful boot.
767
1005
  const handleSdkReady = message => {
768
1006
  if (message.origin !== 'webview') return;
769
1007
  if (message.type !== sdkHasLoadedEventName) return;
770
1008
  clearRecoveryTimer();
1009
+ if (hasEmittedSuccessLog || hasFailed) return;
1010
+ hasEmittedSuccessLog = true;
1011
+ emitSuccessLog({
1012
+ cooldown: successLogCooldown,
1013
+ core,
1014
+ getMeta: phaseTimers.getSuccessMeta,
1015
+ name: 'Embedded webview'
1016
+ }).catch(err => {
1017
+ logger.warn('EmbeddedWebView.emitSuccessLog failed', err);
1018
+ });
771
1019
  };
772
1020
  core.messageTransport.on(handleSdkReady);
773
1021
  const onMessageSub = native.addListener('onMessage', event => {
@@ -861,36 +1109,6 @@ const setupEmbeddedWebView = ({
861
1109
  };
862
1110
  };
863
1111
 
864
- /******************************************************************************
865
- Copyright (c) Microsoft Corporation.
866
-
867
- Permission to use, copy, modify, and/or distribute this software for any
868
- purpose with or without fee is hereby granted.
869
-
870
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
871
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
872
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
873
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
874
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
875
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
876
- PERFORMANCE OF THIS SOFTWARE.
877
- ***************************************************************************** */
878
-
879
- function __awaiter(thisArg, _arguments, P, generator) {
880
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
881
- return new (P || (P = Promise))(function (resolve, reject) {
882
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
883
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
884
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
885
- step((generator = generator.apply(thisArg, _arguments || [])).next());
886
- });
887
- }
888
-
889
- typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
890
- var e = new Error(message);
891
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
892
- };
893
-
894
1112
  const setupPasskeyHandler = core => {
895
1113
  const passkeyRequestChannel = createRequestChannel(core.messageTransport);
896
1114
  passkeyRequestChannel.handle('passkey_register', _a => __awaiter(void 0, [_a], void 0, function* ({