@smileid/web-components 11.4.3 → 11.4.5

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 (34) hide show
  1. package/dist/esm/{DocumentCaptureScreens-D1oMAv4n.js → DocumentCaptureScreens-D2G0NOQr.js} +4 -4
  2. package/dist/esm/{DocumentCaptureScreens-D1oMAv4n.js.map → DocumentCaptureScreens-D2G0NOQr.js.map} +1 -1
  3. package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-uHfA3txP.js} +3 -3
  4. package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-uHfA3txP.js.map} +1 -1
  5. package/dist/esm/{Navigation-nvehze1F.js → Navigation-Bb7MPLE8.js} +2 -2
  6. package/dist/esm/{Navigation-nvehze1F.js.map → Navigation-Bb7MPLE8.js.map} +1 -1
  7. package/dist/esm/{SelfieCaptureScreens-CC-y0CpT.js → SelfieCaptureScreens-Dr7VzON7.js} +2322 -2187
  8. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +1 -0
  9. package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-Depzg0ti.js} +2 -2
  10. package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-Depzg0ti.js.map} +1 -1
  11. package/dist/esm/combobox.js +1 -1
  12. package/dist/esm/document.js +1 -1
  13. package/dist/esm/end-user-consent.js +1 -1
  14. package/dist/esm/{index-5Nn2kzHI.js → index-C4RTMbgw.js} +74 -74
  15. package/dist/esm/{index-5Nn2kzHI.js.map → index-C4RTMbgw.js.map} +1 -1
  16. package/dist/esm/localisation.js +1 -1
  17. package/dist/esm/main.js +6 -6
  18. package/dist/esm/navigation.js +1 -1
  19. package/dist/esm/{package-BxstV9r_.js → package-D6YrpMcO.js} +3 -3
  20. package/dist/esm/{package-BxstV9r_.js.map → package-D6YrpMcO.js.map} +1 -1
  21. package/dist/esm/selfie.js +1 -1
  22. package/dist/esm/smart-camera-web.js +9 -12
  23. package/dist/esm/smart-camera-web.js.map +1 -1
  24. package/dist/esm/totp-consent.js +1 -1
  25. package/dist/smart-camera-web.js +51 -51
  26. package/dist/smart-camera-web.js.map +1 -1
  27. package/dist/types/main.d.ts +2 -0
  28. package/lib/components/selfie/src/SelfieCaptureScreens.js +83 -0
  29. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +275 -88
  30. package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
  31. package/lib/components/signature-pad/package.json +1 -1
  32. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +6 -4
  33. package/package.json +2 -2
  34. package/dist/esm/SelfieCaptureScreens-CC-y0CpT.js.map +0 -1
@@ -242,8 +242,10 @@ export declare class SelfieCaptureScreens extends HTMLElement {
242
242
  }[] | null | undefined;
243
243
  setUpEventListeners(): void;
244
244
  forceWrapperRemount(): Promise<any>;
245
+ restartSelfieCapture(): void;
245
246
  setActiveScreen(screen: any): void;
246
247
  setupSelfieWrapperEventListeners(): void;
248
+ _selfieWrapperPublishHandler: ((event: any) => Promise<void>) | undefined;
247
249
  _publishSelectedImages(): void;
248
250
  get hideInstructions(): boolean;
249
251
  get hideAttribution(): "" | "hide-attribution=\"\"";
@@ -1,6 +1,7 @@
1
1
  import './selfie-capture-instructions';
2
2
  import './selfie-capture-review';
3
3
  import './selfie-capture-wrapper/index.ts';
4
+ import { getMediapipeInstance } from './smartselfie-capture/utils/mediapipeManager.ts';
4
5
  import SmartCamera from '../../../domain/camera/src/SmartCamera';
5
6
  import styles from '../../../styles/src/styles';
6
7
  import packageJson from '../../../../package.json';
@@ -108,6 +109,37 @@ class SelfieCaptureScreens extends HTMLElement {
108
109
  }
109
110
 
110
111
  this.setUpEventListeners();
112
+
113
+ // Pre-warm MediaPipe as soon as the selfie flow starts so the heavy WASM +
114
+ // model download (and on-device init) happens while the user reads the
115
+ // instructions — not behind a blocking spinner on the capture screen. This
116
+ // is fire-and-forget and idempotent: getMediapipeInstance() caches a single
117
+ // in-flight promise / instance, so the wrapper (and any remount) reuses the
118
+ // same load instead of starting a new one. Errors are handled by the
119
+ // wrapper's own retry/fallback path, so swallow them here.
120
+ //
121
+ // Skipped under Cypress to match `SelfieCaptureWrapper`'s existing test
122
+ // seam (`skipMediapipeForTests`). Specs rely on the wrapper short-circuiting
123
+ // to the legacy `selfie-capture` fallback; pre-warming here would race with
124
+ // that seam and (intermittently) flip the wrapper into the SmartSelfie path
125
+ // by populating `window.__smileIdentityMediapipe` before the wrapper mounts.
126
+ //
127
+ // The parent check covers the embed Cypress context where this element runs
128
+ // inside an iframe and window.Cypress is only set on the parent frame.
129
+ const isCypress =
130
+ !!window.Cypress ||
131
+ (() => {
132
+ try {
133
+ return !!window.parent.Cypress;
134
+ } catch {
135
+ return false;
136
+ }
137
+ })() ||
138
+ (window.navigator.userAgent.includes('Electron') && window.__Cypress);
139
+ const forceMediapipeLoad = !!window.__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__;
140
+ if (!isCypress || forceMediapipeLoad) {
141
+ getMediapipeInstance().catch(() => {});
142
+ }
111
143
  }
112
144
 
113
145
  getAgentMode() {
@@ -230,6 +262,45 @@ class SelfieCaptureScreens extends HTMLElement {
230
262
  });
231
263
  }
232
264
 
265
+ // Return to a clean selfie capture screen. Used when navigating back from the
266
+ // document flow. Previously this was driven by toggling the `initial-screen`
267
+ // attribute on this element, which re-fired a full `connectedCallback()`
268
+ // rebuild every time (even when the value was unchanged). We now swap in a
269
+ // fresh `selfie-capture-wrapper` synchronously and navigate explicitly, so we
270
+ // land on a clean capture screen — not the stale review — without rebuilding
271
+ // the whole screen tree or depending on timers.
272
+ restartSelfieCapture() {
273
+ SmartCamera.stopMedia();
274
+
275
+ const container = this.querySelector('div');
276
+ const oldWrapper = this.selfieCapture;
277
+
278
+ if (oldWrapper && container) {
279
+ this._remountKey++;
280
+
281
+ const newWrapper = document.createElement('selfie-capture-wrapper');
282
+ Array.from(oldWrapper.attributes).forEach((attr) => {
283
+ newWrapper.setAttribute(attr.name, attr.value);
284
+ });
285
+ newWrapper.setAttribute('key', this._remountKey.toString());
286
+ newWrapper.setAttribute('start-countdown', 'false');
287
+ newWrapper.setAttribute('hidden', '');
288
+
289
+ const reviewElement = container.querySelector('selfie-capture-review');
290
+ oldWrapper.remove();
291
+ if (reviewElement) {
292
+ container.insertBefore(newWrapper, reviewElement);
293
+ } else {
294
+ container.appendChild(newWrapper);
295
+ }
296
+
297
+ this.selfieCapture = newWrapper;
298
+ this.setupSelfieWrapperEventListeners();
299
+ }
300
+
301
+ this.setActiveScreen(this.selfieCapture);
302
+ }
303
+
233
304
  // Override setActiveScreen to enable countdown when selfie-capture is active
234
305
  setActiveScreen(screen) {
235
306
  if (this.activeScreen === screen) {
@@ -256,6 +327,17 @@ class SelfieCaptureScreens extends HTMLElement {
256
327
  window.removeEventListener(event, handler);
257
328
  });
258
329
  }
330
+ // Also remove the previously-attached element-level publish handler so we
331
+ // don't accumulate duplicates across remounts (each call to
332
+ // setupSelfieWrapperEventListeners would otherwise add another listener,
333
+ // causing multiple transitions to review / multiple metadata events on
334
+ // navigating back from document capture).
335
+ if (this._selfieWrapperPublishHandler) {
336
+ this.removeEventListener(
337
+ 'selfie-capture.publish',
338
+ this._selfieWrapperPublishHandler,
339
+ );
340
+ }
259
341
 
260
342
  // Create new event handlers
261
343
  const cancelledHandler = async () => {
@@ -313,6 +395,7 @@ class SelfieCaptureScreens extends HTMLElement {
313
395
 
314
396
  // Also listen for the publish event on the parent SelfieCaptureScreens element
315
397
  // in case smartselfie-capture dispatches it there
398
+ this._selfieWrapperPublishHandler = publishHandler;
316
399
  this.addEventListener('selfie-capture.publish', publishHandler);
317
400
  }
318
401
 
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'preact/hooks';
1
+ import { useState, useEffect, useRef } from 'preact/hooks';
2
2
  import { IconLoader2 } from '@tabler/icons-preact';
3
3
  import register from 'preact-custom-element';
4
4
  import type { FunctionComponent } from 'preact';
@@ -30,7 +30,7 @@ declare global {
30
30
  }
31
31
 
32
32
  interface Props {
33
- timeout?: number;
33
+ timeout?: string | number;
34
34
  interval?: number;
35
35
  duration?: number;
36
36
  'theme-color'?: string;
@@ -45,20 +45,32 @@ interface Props {
45
45
  hidden?: string | boolean;
46
46
  }
47
47
 
48
- const DEFAULT_MEDIAPIPE_WAIT_MS = 90 * 1000; // For when legacy fallback is NOT allowed, we wait the full 90s for mediapipe to load before showing an error.
49
- const DEFAULT_WAIT_MS = 20 * 1000; // default for when legacy fallback is allowed we wait for 20s
48
+ // Default deadlines for the Mediapipe load attempt. These are used when the
49
+ // host did NOT provide an explicit `timeout` prop; an explicit `timeout`
50
+ // always wins regardless of the legacy-fallback flag.
51
+ // - With legacy fallback allowed: 20s, so users on broken networks get a
52
+ // usable capture quickly.
53
+ // - Without legacy fallback: 90s, so we keep waiting before giving up to
54
+ // the error UI.
55
+ const DEFAULT_MEDIAPIPE_WAIT_MS = 90 * 1000;
56
+ const DEFAULT_WAIT_MS = 20 * 1000;
50
57
  // Cap retries on transient init failures so we don't spin forever, while still
51
58
  // allowing recovery from short-lived issues (e.g. CDN hiccups while the
52
59
  // wrapper is preloading in a hidden state). Retries are spaced with
53
60
  // exponential backoff (base * 2^(attempt-1)) so we don't hammer the CDN.
54
61
  const MAX_MEDIAPIPE_INIT_ATTEMPTS = 3;
55
62
  const MEDIAPIPE_RETRY_BASE_DELAY_MS = 500;
63
+ // Cap user-initiated retries from the failure screen. Each tap resets the
64
+ // per-attempt counter (granting another `MAX_MEDIAPIPE_INIT_ATTEMPTS` round of
65
+ // internal retries), so without this cap a frustrated or malicious user could
66
+ // hammer Retry indefinitely and generate unbounded requests to the model CDN.
67
+ const MAX_USER_RETRIES = 3;
56
68
 
57
69
  // Wrapper component that decides whether to use the modern
58
70
  // SmartSelfieCapture (Mediapipe-based) or fallback to the legacy `selfie-capture`
59
71
  // web component after a timeout (default 90 seconds).
60
72
  const SelfieCaptureWrapper: FunctionComponent<Props> = ({
61
- timeout = DEFAULT_MEDIAPIPE_WAIT_MS,
73
+ timeout,
62
74
  'start-countdown': startCountdownProp = false,
63
75
  'allow-legacy-selfie-fallback': allowLegacySelfieFallbackProp = false,
64
76
  hidden: hiddenProp = false,
@@ -82,29 +94,93 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
82
94
  (window.navigator.userAgent.includes('Electron') &&
83
95
  (window as any).__Cypress);
84
96
 
97
+ // Test-only seam: the `isCypress` short-circuit below skips the whole
98
+ // MediaPipe load path (so most specs run fast against the legacy fallback).
99
+ // A spec that wants to exercise the real load/spinner/error/Retry path sets
100
+ // `window.__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__ = true` to opt out of that
101
+ // short-circuit. When the flag is unset, `skipMediapipeForTests === isCypress`,
102
+ // so production and existing-test behaviour are unchanged.
103
+ const forceMediapipeLoad = !!(window as any)
104
+ .__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__;
105
+ const skipMediapipeForTests = isCypress && !forceMediapipeLoad;
106
+
85
107
  const hidden = getBoolProp(hiddenProp);
86
108
  const startCountdown = getBoolProp(startCountdownProp);
87
109
  const allowLegacySelfieFallback = getBoolProp(allowLegacySelfieFallbackProp);
88
- const loadingTime = allowLegacySelfieFallback ? DEFAULT_WAIT_MS : timeout;
110
+
111
+ // Resolve how long we'll wait for Mediapipe before the hard deadline fires.
112
+ // Precedence:
113
+ // 1. Explicit `timeout` prop (parsed defensively because web component
114
+ // attributes arrive as strings).
115
+ // 2. With legacy fallback allowed: DEFAULT_WAIT_MS (20s).
116
+ // 3. Otherwise: DEFAULT_MEDIAPIPE_WAIT_MS (90s).
117
+ const parsedTimeout = typeof timeout === 'string' ? Number(timeout) : timeout;
118
+ const hasExplicitTimeout =
119
+ typeof parsedTimeout === 'number' &&
120
+ Number.isFinite(parsedTimeout) &&
121
+ parsedTimeout > 0;
122
+ const defaultLoadingTime = allowLegacySelfieFallback
123
+ ? DEFAULT_WAIT_MS
124
+ : DEFAULT_MEDIAPIPE_WAIT_MS;
125
+ const loadingTime = hasExplicitTimeout
126
+ ? (parsedTimeout as number)
127
+ : defaultLoadingTime;
89
128
 
90
129
  // Component state:
91
130
  // - mediapipeReady: whether the mediapipe instance has successfully loaded
92
- // - loadingProgress: percentage used for the visible loading UI
131
+ // - loadingProgress: percentage used for the visible loading UI (cosmetic)
132
+ // - loadDeadlineExceeded: hard cap signal — once true, we stop waiting for
133
+ // Mediapipe and commit to the legacy fallback (or error UI). Kept
134
+ // separate from `loadingProgress` so the decision is driven by a single
135
+ // setTimeout firing once, not by a 200ms ticking interval that can race
136
+ // with the Mediapipe promise resolution.
93
137
  // - initialSessionCompleted: set when the legacy component emits publish/cancel/close
94
138
  // - mediapipeLoading: true while attempting to load mediapipe
95
139
  // - usingSelfieCapture: whether we've mounted the legacy `selfie-capture` element
96
- const [mediapipeReady, setMediapipeReady] = useState(false);
97
- const [loadingProgress, setLoadingProgress] = useState(isCypress ? 100 : 0);
140
+ // If MediaPipe already loaded earlier in this session (e.g. we're remounting
141
+ // after returning from document capture), reuse the cached singleton instance
142
+ // immediately instead of showing the loading spinner again. The model and
143
+ // WASM are already in memory, so no network call is needed.
144
+ const mediapipeAlreadyLoaded = !!(
145
+ window.__smileIdentityMediapipe?.loaded &&
146
+ window.__smileIdentityMediapipe?.instance
147
+ );
148
+
149
+ const [mediapipeReady, setMediapipeReady] = useState(mediapipeAlreadyLoaded);
150
+ const [loadingProgress, setLoadingProgress] = useState(
151
+ skipMediapipeForTests || mediapipeAlreadyLoaded ? 100 : 0,
152
+ );
153
+ const [loadDeadlineExceeded, setLoadDeadlineExceeded] = useState(
154
+ skipMediapipeForTests,
155
+ );
98
156
  const [initialSessionCompleted, setInitialSessionCompleted] = useState(false);
99
157
  const [mediapipeLoading, setMediapipeLoading] = useState(false);
158
+ // Concurrency guard for the load effect. Kept in a ref — NOT in
159
+ // `mediapipeLoading` state — so the effect's guard does not depend on a value
160
+ // the effect itself sets. If it did (as it used to), calling
161
+ // `setMediapipeLoading(true)` would re-run the effect, whose cleanup flips
162
+ // `cancelled` true, and a slow `getMediapipeInstance()` would then resolve
163
+ // into a cancelled closure — never calling `setMediapipeReady(true)`. That
164
+ // left the UI stuck on the loading spinner even though MediaPipe loaded
165
+ // successfully (intermittent: only when the load lost the race to the
166
+ // re-render).
167
+ const mediapipeLoadingRef = useRef(false);
168
+ // Bumped to re-trigger the load effect for a bounded retry after a transient
169
+ // failure (replaces re-using `mediapipeLoading` as the re-trigger signal).
170
+ const [mediapipeRetryTick, setMediapipeRetryTick] = useState(0);
100
171
  // `unsupportedEnvironment` is a permanent, one-shot signal: we know
101
172
  // MediaPipe cannot run here, so stop trying.
102
173
  const [unsupportedEnvironment, setUnsupportedEnvironment] = useState(false);
103
- // Bounded retry counter for transient init failures.
104
- const [mediapipeInitAttempts, setMediapipeInitAttempts] = useState(0);
174
+ // Bounded retry counter for transient init failures. Stored in a ref so
175
+ // incrementing it does not trigger a re-render — it's only read inside the
176
+ // load effect.
177
+ const mediapipeInitAttemptsRef = useRef(0);
105
178
  // Dedup flag so we only report a given init failure to Sentry once per
106
- // wrapper instance, even if we end up retrying.
107
- const [mediapipeInitReported, setMediapipeInitReported] = useState(false);
179
+ // wrapper instance, even if we end up retrying. Ref for the same reason.
180
+ const mediapipeInitReportedRef = useRef(false);
181
+ // User-initiated Retry presses are also bounded — see `MAX_USER_RETRIES`.
182
+ const userRetryCountRef = useRef(0);
183
+ const [retriesExhausted, setRetriesExhausted] = useState(false);
108
184
  const [usingSelfieCapture, setUsingSelfieCapture] = useState(false);
109
185
 
110
186
  // Attempt to load Mediapipe (with a small bounded retry budget). If
@@ -115,27 +191,33 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
115
191
  useEffect(() => {
116
192
  if (
117
193
  mediapipeReady ||
118
- mediapipeLoading ||
194
+ mediapipeLoadingRef.current ||
119
195
  unsupportedEnvironment ||
120
- mediapipeInitAttempts >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
121
- isCypress
196
+ mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
197
+ skipMediapipeForTests
122
198
  )
123
199
  return undefined;
124
200
 
125
201
  let cancelled = false;
126
202
  let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
127
203
 
204
+ // Mark loading via the ref (effect guard) and the state (UI). The state is
205
+ // intentionally NOT in this effect's deps — see `mediapipeLoadingRef`.
206
+ mediapipeLoadingRef.current = true;
207
+ setMediapipeLoading(true);
208
+
128
209
  const loadMediapipe = async () => {
129
- setMediapipeLoading(true);
130
- const attemptNumber = mediapipeInitAttempts + 1;
131
- setMediapipeInitAttempts(attemptNumber);
210
+ const attemptNumber = mediapipeInitAttemptsRef.current + 1;
211
+ mediapipeInitAttemptsRef.current = attemptNumber;
132
212
  try {
133
213
  await getMediapipeInstance();
134
214
  if (cancelled) return;
215
+ mediapipeLoadingRef.current = false;
135
216
  setMediapipeReady(true);
136
217
  setMediapipeLoading(false);
137
218
  } catch (error) {
138
219
  if (cancelled) return;
220
+ mediapipeLoadingRef.current = false;
139
221
  // Loading failed; we'll fall back to the legacy selfie-capture component
140
222
  // after the loadingProgress reaches 100% (or sooner for definitively
141
223
  // unsupported environments — see below).
@@ -145,8 +227,8 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
145
227
  // Report to Sentry (when the host page has exposed it on window) so we
146
228
  // can observe how often users land on the fallback path and which
147
229
  // environments are affected. Dedup so retries don't flood Sentry.
148
- if (!mediapipeInitReported) {
149
- setMediapipeInitReported(true);
230
+ if (!mediapipeInitReportedRef.current) {
231
+ mediapipeInitReportedRef.current = true;
150
232
  window.Sentry?.captureException(error, {
151
233
  tags: {
152
234
  area: 'mediapipe_init',
@@ -164,6 +246,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
164
246
  if (isUnsupportedEnvironment) {
165
247
  setUnsupportedEnvironment(true);
166
248
  setLoadingProgress(100);
249
+ setLoadDeadlineExceeded(true);
167
250
  setMediapipeLoading(false);
168
251
  return;
169
252
  }
@@ -182,6 +265,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
182
265
  retryTimeoutId = null;
183
266
  if (cancelled) return;
184
267
  setMediapipeLoading(false);
268
+ // Re-trigger the effect for the next attempt via a dedicated counter
269
+ // rather than toggling `mediapipeLoading` (which is no longer a dep).
270
+ setMediapipeRetryTick((tick) => tick + 1);
185
271
  }, backoffMs);
186
272
  }
187
273
  };
@@ -190,35 +276,88 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
190
276
 
191
277
  return () => {
192
278
  cancelled = true;
279
+ mediapipeLoadingRef.current = false;
193
280
  if (retryTimeoutId !== null) {
194
281
  clearTimeout(retryTimeoutId);
195
282
  }
196
283
  };
197
- }, [
198
- mediapipeReady,
199
- mediapipeLoading,
200
- unsupportedEnvironment,
201
- mediapipeInitAttempts,
202
- mediapipeInitReported,
203
- ]);
204
-
205
- // When using the loading countdown (startCountdown), increment the
206
- // visible loading progress. This is only used while mediapipe hasn't
207
- // reported ready; once mediapipeReady becomes true we stop the timer.
284
+ // `mediapipeLoading` is deliberately excluded: it is set inside this effect,
285
+ // so depending on it would re-run the effect and cancel the in-flight load.
286
+ }, [mediapipeReady, unsupportedEnvironment, mediapipeRetryTick]);
287
+
288
+ // Cosmetic loading progress: ticks 0→100 over `loadingTime` so the UI can
289
+ // show "slow connection" copy past the SLOW_CONNECTION_THRESHOLD. This is
290
+ // purely visual — it does NOT decide when we fall back. The decision is
291
+ // driven by `loadDeadlineExceeded` below.
208
292
  useEffect(() => {
209
293
  if (hidden || !startCountdown || mediapipeReady) return undefined;
210
294
 
211
295
  const timer = setInterval(() => {
212
- setLoadingProgress((prev: number) => Math.min(prev + 1, 100));
296
+ setLoadingProgress((prev: number) => {
297
+ if (prev >= 100) {
298
+ clearInterval(timer);
299
+ return 100;
300
+ }
301
+ return prev + 1;
302
+ });
213
303
  }, loadingTime / 100);
214
304
 
215
305
  return () => {
216
306
  clearInterval(timer);
217
307
  };
218
- }, [hidden, startCountdown, loadingTime, mediapipeReady]);
308
+ // `mediapipeRetryTick` restarts the cosmetic progress when the user taps
309
+ // Retry (which resets `loadingProgress` to 0).
310
+ }, [hidden, startCountdown, loadingTime, mediapipeReady, mediapipeRetryTick]);
311
+
312
+ // Hard deadline: a single setTimeout that flips `loadDeadlineExceeded`
313
+ // exactly once. This is the signal the render path uses to commit to the
314
+ // fallback. Skipped when hidden, when Mediapipe is already ready, or under
315
+ // Cypress (where the flag is pre-seeded to true).
316
+ useEffect(() => {
317
+ if (
318
+ hidden ||
319
+ mediapipeReady ||
320
+ loadDeadlineExceeded ||
321
+ skipMediapipeForTests
322
+ )
323
+ return undefined;
324
+
325
+ const id = setTimeout(() => {
326
+ setLoadDeadlineExceeded(true);
327
+ }, loadingTime);
328
+
329
+ return () => clearTimeout(id);
330
+ }, [
331
+ hidden,
332
+ mediapipeReady,
333
+ loadDeadlineExceeded,
334
+ loadingTime,
335
+ skipMediapipeForTests,
336
+ ]);
337
+
338
+ // Latch the legacy fallback decision in an effect rather than during
339
+ // render. Effects only run after commit, so by the time this runs, any
340
+ // Mediapipe-ready update scheduled in the same batch as the deadline tick
341
+ // will already be visible — closing the race where `setLoadDeadlineExceeded`
342
+ // and `setMediapipeReady` fire in adjacent microtasks.
343
+ useEffect(() => {
344
+ if (hidden || usingSelfieCapture || mediapipeReady) return;
345
+ if (!loadDeadlineExceeded) return;
346
+ const legacyFallbackAllowed =
347
+ allowLegacySelfieFallback || skipMediapipeForTests;
348
+ if (!legacyFallbackAllowed) return;
349
+ setUsingSelfieCapture(true);
350
+ }, [
351
+ hidden,
352
+ usingSelfieCapture,
353
+ mediapipeReady,
354
+ loadDeadlineExceeded,
355
+ allowLegacySelfieFallback,
356
+ skipMediapipeForTests,
357
+ ]);
219
358
 
220
359
  useEffect(() => {
221
- if (hidden || mediapipeReady || loadingProgress < 100) return undefined;
360
+ if (hidden || mediapipeReady || !loadDeadlineExceeded) return undefined;
222
361
 
223
362
  const setupEventForwarding = () => {
224
363
  const selfieCapture = document.querySelector('selfie-capture');
@@ -267,7 +406,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
267
406
  return () => {
268
407
  clearTimeout(timeoutId);
269
408
  };
270
- }, [hidden, mediapipeReady, loadingProgress]);
409
+ }, [hidden, mediapipeReady, loadDeadlineExceeded]);
271
410
 
272
411
  // Dispatch allow_legacy_selfie_fallback config for observability
273
412
  useEffect(() => {
@@ -298,6 +437,28 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
298
437
  );
299
438
  }, [usingSelfieCapture, hidden, mediapipeLoading]);
300
439
 
440
+ // Retry from the failure screen: clear the give-up state and re-arm the load
441
+ // effect, deadline, and cosmetic progress so we make a fresh attempt. We reset
442
+ // `unsupportedEnvironment` so a genuinely unsupported device fails fast again
443
+ // (rather than spinning for the full deadline) instead of being permanently
444
+ // latched off. User taps are capped by `MAX_USER_RETRIES` so a frustrated or
445
+ // malicious user can't hammer Retry indefinitely and generate unbounded
446
+ // requests to the model CDN.
447
+ const handleRetry = () => {
448
+ if (userRetryCountRef.current >= MAX_USER_RETRIES) {
449
+ setRetriesExhausted(true);
450
+ return;
451
+ }
452
+ userRetryCountRef.current += 1;
453
+ mediapipeInitAttemptsRef.current = 0;
454
+ mediapipeInitReportedRef.current = false;
455
+ mediapipeLoadingRef.current = false;
456
+ setUnsupportedEnvironment(false);
457
+ setLoadDeadlineExceeded(false);
458
+ setLoadingProgress(0);
459
+ setMediapipeRetryTick((tick) => tick + 1);
460
+ };
461
+
301
462
  if (hidden) {
302
463
  return null;
303
464
  }
@@ -312,65 +473,91 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
312
473
  return <SmartSelfieCapture {...props} />;
313
474
  }
314
475
 
315
- if (loadingProgress >= 100) {
316
- // When loading completes without Mediapipe becoming ready, check if legacy
317
- // fallback is allowed. Legacy is allowed if:
318
- // 1. allow-legacy-selfie-fallback attribute is set to true, OR
319
- // 2. Running under Cypress (to keep existing test behavior)
320
- const legacyFallbackAllowed = allowLegacySelfieFallback || isCypress;
321
-
322
- if (legacyFallbackAllowed) {
323
- // Mount the legacy `selfie-capture` web component. We also set
324
- // `usingSelfieCapture` so other effects can react (e.g. metadata dispatch).
325
- if (!usingSelfieCapture) {
326
- setUsingSelfieCapture(true);
327
- }
476
+ // Legacy capture is mounted only once the latch effect has set
477
+ // `usingSelfieCapture`. The effect re-checks `mediapipeReady` after commit,
478
+ // so if Mediapipe became ready in the same batch as the deadline tick, the
479
+ // latch is skipped and the SmartSelfie branch above wins. While we're
480
+ // waiting for the effect to fire (a single microtask), keep showing the
481
+ // spinner instead of flashing legacy.
482
+ if (usingSelfieCapture) {
483
+ const propsWithoutHidden = { ...props };
484
+ delete (propsWithoutHidden as any).hidden;
328
485
 
329
- const propsWithoutHidden = { ...props };
330
- delete (propsWithoutHidden as any).hidden;
486
+ return (
487
+ // @ts-expect-error --- preact-custom-element doesn't have proper types for refs
488
+ <selfie-capture
489
+ {...propsWithoutHidden}
490
+ ref={(el: HTMLElement) => {
491
+ if (el && !el.hasAttribute('data-events-setup')) {
492
+ el.setAttribute('data-events-setup', 'true');
493
+
494
+ const forwardEvent = (event: Event) => {
495
+ const customEvent = event as CustomEvent;
496
+
497
+ if (
498
+ customEvent.type === 'selfie-capture.publish' ||
499
+ customEvent.type === 'selfie-capture.cancelled' ||
500
+ customEvent.type === 'selfie-capture.close'
501
+ ) {
502
+ setInitialSessionCompleted(true);
503
+ }
504
+
505
+ window.dispatchEvent(
506
+ new CustomEvent(customEvent.type, {
507
+ detail: customEvent.detail,
508
+ bubbles: true,
509
+ }),
510
+ );
511
+ };
512
+
513
+ el.addEventListener('selfie-capture.publish', forwardEvent);
514
+ el.addEventListener('selfie-capture.cancelled', forwardEvent);
515
+ el.addEventListener('selfie-capture.close', forwardEvent);
516
+ }
517
+ }}
518
+ />
519
+ );
520
+ }
331
521
 
332
- return (
333
- // @ts-expect-error --- preact-custom-element doesn't have proper types for refs
334
- <selfie-capture
335
- {...propsWithoutHidden}
336
- ref={(el: HTMLElement) => {
337
- if (el && !el.hasAttribute('data-events-setup')) {
338
- el.setAttribute('data-events-setup', 'true');
339
-
340
- const forwardEvent = (event: Event) => {
341
- const customEvent = event as CustomEvent;
342
-
343
- if (
344
- customEvent.type === 'selfie-capture.publish' ||
345
- customEvent.type === 'selfie-capture.cancelled' ||
346
- customEvent.type === 'selfie-capture.close'
347
- ) {
348
- setInitialSessionCompleted(true);
349
- }
350
-
351
- window.dispatchEvent(
352
- new CustomEvent(customEvent.type, {
353
- detail: customEvent.detail,
354
- bubbles: true,
355
- }),
356
- );
357
- };
358
-
359
- el.addEventListener('selfie-capture.publish', forwardEvent);
360
- el.addEventListener('selfie-capture.cancelled', forwardEvent);
361
- el.addEventListener('selfie-capture.close', forwardEvent);
362
- }
363
- }}
364
- />
365
- );
366
- }
522
+ // Hard deadline elapsed and legacy fallback isn't allowed: show an actionable
523
+ // error. The network is usually fine here (the hold-up is on-device setup), so
524
+ // only blame the connection when the browser actually reports being offline —
525
+ // otherwise frame it as a setup problem. Always offer a Retry control.
526
+ if (
527
+ loadDeadlineExceeded &&
528
+ !(allowLegacySelfieFallback || skipMediapipeForTests)
529
+ ) {
530
+ const isOffline =
531
+ typeof navigator !== 'undefined' && navigator.onLine === false;
532
+ const errorKey = isOffline
533
+ ? 'selfie.capture.loading.offlineError'
534
+ : 'selfie.capture.loading.setupError';
535
+ const themeColor = (props as Record<string, string>)['theme-color'];
367
536
 
368
- // Legacy fallback is NOT allowed: show error message
369
537
  return (
370
538
  <div style={{ textAlign: 'center', marginTop: '20%', padding: '0 20px' }}>
371
539
  <p style={{ fontSize: '1.2rem', color: '#333' }}>
372
- {translate('selfie.capture.loading.connectionError')}
540
+ {translate(errorKey)}
373
541
  </p>
542
+ <button
543
+ type="button"
544
+ onClick={handleRetry}
545
+ disabled={retriesExhausted}
546
+ style={{
547
+ marginTop: '16px',
548
+ padding: '0.75rem 1.5rem',
549
+ borderRadius: '2.5rem',
550
+ border: 'none',
551
+ backgroundColor: themeColor || '#001096',
552
+ color: '#fff',
553
+ fontSize: '1rem',
554
+ fontWeight: 600,
555
+ cursor: retriesExhausted ? 'not-allowed' : 'pointer',
556
+ opacity: retriesExhausted ? 0.6 : 1,
557
+ }}
558
+ >
559
+ {translate('selfie.capture.loading.retry')}
560
+ </button>
374
561
  </div>
375
562
  );
376
563
  }