@smileid/usesmileid 12.0.1 → 12.0.2

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 (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -227,6 +227,8 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
227
227
  // branch idempotent without latching the one-shot delivery gate.
228
228
  const [captureTimedOut, setCaptureTimedOut] = useState(false);
229
229
  const timedOutRef = useRef(false);
230
+ // Set when streaming produced no analyzed frame at all, which the analyzer's own timeout cannot report.
231
+ const [captureStalled, setCaptureStalled] = useState(false);
230
232
  // True while a manual/gallery delivery is in flight — disables the controls.
231
233
  const [deliveringDocument, setDeliveringDocument] = useState(false);
232
234
  // Transient gallery-import error; overrides the live chip, self-clears so the user can re-pick.
@@ -282,8 +284,28 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
282
284
  setSessionEpoch((epoch) => epoch + 1);
283
285
  // A new session must not compare against the old session's last state.
284
286
  previousScanStateRef.current = null;
285
- setStreaming(true);
286
- }, [streaming, onStartCapture]);
287
+ // Clear the terminal latches so a retry after a timeout or a stall is a fresh session.
288
+ const wasTerminal = timedOutRef.current;
289
+ timedOutRef.current = false;
290
+ setCaptureTimedOut(false);
291
+ setCaptureStalled(false);
292
+ setScanState({ type: UseSmileIDScanStateType.Initial });
293
+ setHasAnalyzedFrame(false);
294
+ // Frames banked before the abandoned session must not pad this one.
295
+ capturedEventsRef.current = [];
296
+ if (wasTerminal) {
297
+ // A timed-out native session is terminal — stream only after it resets,
298
+ // or the first retry frame re-reports Timeout and re-latches the error.
299
+ void props.analyzer
300
+ .reset()
301
+ .catch((error) => UseSmileIDCrashReporting.captureException(error))
302
+ .finally(() => {
303
+ if (!completedRef.current) setStreaming(true);
304
+ });
305
+ } else {
306
+ setStreaming(true);
307
+ }
308
+ }, [streaming, onStartCapture, props.analyzer]);
287
309
 
288
310
  const analyzer = props.analyzer;
289
311
  const onCaptureComplete = props.onCaptureComplete;
@@ -523,7 +545,8 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
523
545
  // Terminal states: finished → flush + complete; timeout → failure
524
546
  // without flushing (no captured artifacts to wait on).
525
547
  if (result.state.type === UseSmileIDScanStateType.Finished) {
526
- if (completedRef.current) return;
548
+ // `timedOutRef` too: stopping the stream is async, so a frame queued before the timeout can still land here.
549
+ if (completedRef.current || timedOutRef.current) return;
527
550
  completedRef.current = true;
528
551
  setStreaming(false);
529
552
  // flush() waits for every emitted JPG to land on disk before we
@@ -542,15 +565,9 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
542
565
  } else if (result.state.type === UseSmileIDScanStateType.Timeout) {
543
566
  if (completedRef.current || timedOutRef.current) return;
544
567
  setStreaming(false);
545
- if (isDocumentCapture) {
546
- // Document capture has no Start button, so a timeout must leave a way
547
- // forward: reveal the manual shutter and keep the delivery gate open
548
- // so the tap can still produce a capture.
549
- timedOutRef.current = true;
550
- setCaptureTimedOut(true);
551
- } else {
552
- completedRef.current = true;
553
- }
568
+ // The terminal state rides on `timedOutRef`, not the one-shot delivery gate, so the shutter and Start button stay usable.
569
+ timedOutRef.current = true;
570
+ setCaptureTimedOut(true);
554
571
  onCaptureTimeout?.(result.state);
555
572
  }
556
573
  },
@@ -573,6 +590,9 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
573
590
  );
574
591
  const isInstructionState =
575
592
  scanState.type === UseSmileIDScanStateType.Initial && !streaming;
593
+ // A selfie timeout leaves `scanState` on Timeout, so the instruction predicate alone would hide the only way to retry.
594
+ const showStartButton =
595
+ (isInstructionState || captureTimedOut) && !isDocumentCapture;
576
596
  const canStart = !streaming && !completedRef.current;
577
597
 
578
598
  const previewReady = hasPermission && device != null;
@@ -587,6 +607,28 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
587
607
  [analyzer.frameProcessorPluginName],
588
608
  );
589
609
 
610
+ // Every terminal signal arrives inside a frame result, so a camera that delivers no frame at all would sit on the initial state forever.
611
+ // Latest-ref, not a dep: an inline `onCaptureTimeout` prop is a fresh identity every render, which would restart the wall clock.
612
+ const onCaptureTimeoutRef = useRef(onCaptureTimeout);
613
+ onCaptureTimeoutRef.current = onCaptureTimeout;
614
+ useEffect(() => {
615
+ if (!streaming || hasAnalyzedFrame) return;
616
+ const timer = setTimeout(() => {
617
+ if (completedRef.current || timedOutRef.current) return;
618
+ timedOutRef.current = true;
619
+ setCaptureStalled(true);
620
+ setCaptureTimedOut(true);
621
+ setStreaming(false);
622
+ onCaptureTimeoutRef.current?.({ type: UseSmileIDScanStateType.Timeout });
623
+ }, CameraDefaults.firstFrameTimeoutMs);
624
+ return () => clearTimeout(timer);
625
+ }, [streaming, hasAnalyzedFrame]);
626
+
627
+ // A frame disproves the stall; document capture has no Start button to clear it by hand.
628
+ useEffect(() => {
629
+ if (hasAnalyzedFrame) setCaptureStalled(false);
630
+ }, [hasAnalyzedFrame]);
631
+
590
632
  // A missing plugin means no frame ever produces a result — report once
591
633
  // on capture start rather than sitting silently on the initial state.
592
634
  const pluginErrorReportedRef = useRef(false);
@@ -732,10 +774,12 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
732
774
  enableEnhancedLiveness,
733
775
  animationDirection: uiState.animationDirection,
734
776
  poseMatched: uiState.poseMatched,
735
- // A transient gallery-import error preempts the live feedback chip.
736
- feedbackKey: galleryErrorKey ?? settledFeedbackKey,
777
+ // A stalled camera, then a transient gallery-import error, preempt the live feedback chip.
778
+ feedbackKey: captureStalled
779
+ ? "si_capture_camera_unavailable"
780
+ : (galleryErrorKey ?? settledFeedbackKey),
737
781
  // Documents auto-start (no Start button); selfie keeps its.
738
- showCaptureButton: isInstructionState && !isDocumentCapture,
782
+ showCaptureButton: showStartButton,
739
783
  showManualShutter: manualShutterAvailable,
740
784
  showGalleryUpload: galleryAvailable,
741
785
  isBackSide: documentSide === DocumentSide.Back,
@@ -754,7 +798,8 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
754
798
  uiState.poseMatched,
755
799
  settledFeedbackKey,
756
800
  galleryErrorKey,
757
- isInstructionState,
801
+ captureStalled,
802
+ showStartButton,
758
803
  isDocumentCapture,
759
804
  manualShutterAvailable,
760
805
  galleryAvailable,
@@ -834,8 +879,14 @@ export function UseSmileIDCaptureScreen(props: UseSmileIDCaptureScreenProps) {
834
879
  <UseSmileIDThemeProvider value={theme}>
835
880
  {/* Translucent status bar → full-bleed window so the oval's reference
836
881
  frame is the full screen (no-op on iOS). On-device-pending: residual
837
- inset also depends on the host SafeAreaProvider config. */}
838
- <StatusBar translucent backgroundColor="transparent" />
882
+ inset also depends on the host SafeAreaProvider config.
883
+ `dark-content` because the capture overlay draws a near-opaque white
884
+ scrim behind the status bar, whatever the host app's own bar style. */}
885
+ <StatusBar
886
+ translucent
887
+ backgroundColor="transparent"
888
+ barStyle="dark-content"
889
+ />
839
890
  <UseSmileIDCaptureContent
840
891
  state={contentState}
841
892
  captureProgress={uiState.captureProgress}
@@ -0,0 +1,166 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ vi,
6
+ beforeAll,
7
+ beforeEach,
8
+ afterEach,
9
+ } from "vitest";
10
+ import * as React from "react";
11
+ import { create as createRenderer, act } from "react-test-renderer";
12
+ import Module from "node:module";
13
+ import { DM_SANS_FONT_NAMES } from "../theme";
14
+
15
+ /**
16
+ * The hook `require()`s the bundled `.ttf` assets — Metro resolves those to
17
+ * numeric asset ids, Node does not. Register a CommonJS handler that mimics
18
+ * Metro's numeric id so the font map can be asserted without a bundler.
19
+ */
20
+ const nodeExtensions = (
21
+ Module as unknown as {
22
+ _extensions: Record<
23
+ string,
24
+ (mod: { exports: unknown }, filename: string) => void
25
+ >;
26
+ }
27
+ )._extensions;
28
+
29
+ let nextAssetId = 0;
30
+
31
+ beforeAll(() => {
32
+ nodeExtensions[".ttf"] = (mod) => {
33
+ nextAssetId += 1;
34
+ mod.exports = nextAssetId;
35
+ };
36
+ });
37
+
38
+ type FontsModule = typeof import("../use_use_smile_id_fonts");
39
+ type ExpoFontModule = { loadAsync: ReturnType<typeof vi.fn> };
40
+
41
+ /** Fresh copy of the module so its one-shot `loadPromise` latch is reset. */
42
+ async function freshModules(): Promise<{
43
+ fonts: FontsModule;
44
+ expoFont: ExpoFontModule;
45
+ }> {
46
+ vi.resetModules();
47
+ const expoFont = (await import("expo-font")) as unknown as ExpoFontModule;
48
+ // The shared setup's `expo-font` mock survives `resetModules`, so its spy
49
+ // carries calls across tests unless cleared here.
50
+ expoFont.loadAsync.mockClear();
51
+ const fonts = (await import("../use_use_smile_id_fonts")) as FontsModule;
52
+ return { fonts, expoFont };
53
+ }
54
+
55
+ let warn: ReturnType<typeof vi.spyOn>;
56
+
57
+ beforeEach(() => {
58
+ warn = vi.spyOn(console, "warn").mockImplementation(() => {});
59
+ });
60
+
61
+ afterEach(() => {
62
+ warn.mockRestore();
63
+ });
64
+
65
+ describe("loadUseSmileIDFonts", () => {
66
+ it("registers every bundled DM Sans weight under its typography face name", async () => {
67
+ const { fonts, expoFont } = await freshModules();
68
+
69
+ await fonts.loadUseSmileIDFonts();
70
+
71
+ expect(expoFont.loadAsync).toHaveBeenCalledTimes(1);
72
+ const [map] = expoFont.loadAsync.mock.calls[0] as [Record<string, number>];
73
+ expect(Object.keys(map).sort()).toEqual(
74
+ Object.values(DM_SANS_FONT_NAMES).sort(),
75
+ );
76
+ for (const face of Object.values(DM_SANS_FONT_NAMES)) {
77
+ expect(typeof map[face]).toBe("number");
78
+ }
79
+ });
80
+
81
+ it("shares one in-flight load across concurrent flows", async () => {
82
+ const { fonts, expoFont } = await freshModules();
83
+
84
+ const [first, second] = [
85
+ fonts.loadUseSmileIDFonts(),
86
+ fonts.loadUseSmileIDFonts(),
87
+ ];
88
+ await Promise.all([first, second]);
89
+ await fonts.loadUseSmileIDFonts();
90
+
91
+ expect(first).toBe(second);
92
+ expect(expoFont.loadAsync).toHaveBeenCalledTimes(1);
93
+ });
94
+ });
95
+
96
+ describe("useUseSmileIDFonts", () => {
97
+ function renderHook(useHook: () => boolean) {
98
+ const seen: boolean[] = [];
99
+ function Probe() {
100
+ seen.push(useHook());
101
+ return null;
102
+ }
103
+ let renderer!: ReturnType<typeof createRenderer>;
104
+ act(() => {
105
+ renderer = createRenderer(<Probe />);
106
+ });
107
+ return { seen, renderer };
108
+ }
109
+
110
+ async function settle() {
111
+ await act(async () => {
112
+ await new Promise((resolve) => setTimeout(resolve, 0));
113
+ });
114
+ }
115
+
116
+ it("renders before the faces are registered, then flips to loaded", async () => {
117
+ const { fonts } = await freshModules();
118
+
119
+ const { seen } = renderHook(fonts.useUseSmileIDFonts);
120
+ expect(seen[0]).toBe(false);
121
+
122
+ await settle();
123
+
124
+ expect(seen.at(-1)).toBe(true);
125
+ });
126
+
127
+ it("never blocks the flow when font registration fails — warns and falls back", async () => {
128
+ const { fonts, expoFont } = await freshModules();
129
+ expoFont.loadAsync.mockRejectedValueOnce(new Error("asset registry down"));
130
+
131
+ const { seen } = renderHook(fonts.useUseSmileIDFonts);
132
+ await settle();
133
+
134
+ expect(seen.at(-1)).toBe(false);
135
+ expect(warn).toHaveBeenCalledWith(
136
+ expect.stringContaining("asset registry down"),
137
+ );
138
+ });
139
+
140
+ it("reports a non-Error rejection without crashing on `.message`", async () => {
141
+ const { fonts, expoFont } = await freshModules();
142
+ expoFont.loadAsync.mockRejectedValueOnce("native module missing");
143
+
144
+ renderHook(fonts.useUseSmileIDFonts);
145
+ await settle();
146
+
147
+ expect(warn).toHaveBeenCalledWith(
148
+ expect.stringContaining("native module missing"),
149
+ );
150
+ });
151
+
152
+ it("stops tracking the load after unmount", async () => {
153
+ const { fonts } = await freshModules();
154
+
155
+ const { renderer, seen } = renderHook(fonts.useUseSmileIDFonts);
156
+ expect(seen).toEqual([false]);
157
+ act(() => {
158
+ renderer.unmount();
159
+ });
160
+ await settle();
161
+
162
+ // The load resolves after the screen is gone; the cleanup latch keeps it
163
+ // from re-rendering the unmounted tree.
164
+ expect(seen).toEqual([false]);
165
+ });
166
+ });