@envive-ai/react-hooks 0.3.63 → 0.3.65

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 (112) hide show
  1. package/dist/application/models/featureGates.cjs +2 -2
  2. package/dist/application/models/featureGates.d.cts +2 -2
  3. package/dist/application/models/featureGates.d.ts +2 -2
  4. package/dist/application/models/featureGates.js +2 -2
  5. package/dist/application/utils/elementObserver.d.cts +2 -2
  6. package/dist/atoms/app/index.d.cts +7 -7
  7. package/dist/atoms/app/index.d.ts +1 -1
  8. package/dist/atoms/app/variant.d.ts +6 -6
  9. package/dist/atoms/chat/chatState.d.cts +19 -19
  10. package/dist/atoms/chat/chatState.d.ts +19 -19
  11. package/dist/atoms/chat/form.d.cts +2 -2
  12. package/dist/atoms/chat/form.d.ts +3 -3
  13. package/dist/atoms/chat/index.d.ts +3 -3
  14. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  15. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  16. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  17. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  18. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  19. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  20. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  21. package/dist/atoms/chat/replies.d.cts +3 -3
  22. package/dist/atoms/chat/suggestions.d.ts +3 -3
  23. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  24. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  25. package/dist/atoms/org/customerService.d.cts +6 -6
  26. package/dist/atoms/org/customerService.d.ts +6 -6
  27. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  28. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  29. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  30. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  31. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  32. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  33. package/dist/atoms/search/types.d.cts +1 -1
  34. package/dist/atoms/search/utils.d.cts +1 -1
  35. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  36. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  37. package/dist/contexts/pageContext/pageContext.cjs +12 -24
  38. package/dist/contexts/pageContext/pageContext.js +12 -24
  39. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  40. package/dist/contexts/types.d.cts +1 -1
  41. package/dist/contexts/types.d.ts +1 -1
  42. package/dist/contexts/typesV3.cjs +8 -1
  43. package/dist/contexts/typesV3.d.cts +23 -6
  44. package/dist/contexts/typesV3.d.ts +23 -6
  45. package/dist/contexts/typesV3.js +8 -2
  46. package/dist/hooks/Intersection/useIntersection.cjs +9 -2
  47. package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
  48. package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
  49. package/dist/hooks/Intersection/useIntersection.js +9 -2
  50. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  51. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
  52. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
  53. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
  54. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
  55. package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
  56. package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
  57. package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
  58. package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
  59. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
  60. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
  61. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
  62. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
  63. package/dist/hooks/utils.d.cts +1 -1
  64. package/dist/hooks/utils.d.ts +1 -1
  65. package/dist/services/amplitudeService/eventNames.cjs +2 -1
  66. package/dist/services/amplitudeService/eventNames.d.cts +2 -1
  67. package/dist/services/amplitudeService/eventNames.d.ts +2 -1
  68. package/dist/services/amplitudeService/eventNames.js +2 -1
  69. package/dist/services/enviveConfigService/enviveConfigService.cjs +21 -35
  70. package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -2
  71. package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -2
  72. package/dist/services/enviveConfigService/enviveConfigService.js +21 -35
  73. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +15 -16
  74. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +15 -16
  75. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +1 -69
  76. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -69
  77. package/dist/services/featureFlagService/index.cjs +7 -5
  78. package/dist/services/featureFlagService/index.d.cts +2 -1
  79. package/dist/services/featureFlagService/index.d.ts +2 -1
  80. package/dist/services/featureFlagService/index.js +7 -5
  81. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
  82. package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
  83. package/dist/services/hardcopyService/hardcopyService.cjs +12 -3
  84. package/dist/services/hardcopyService/hardcopyService.d.cts +3 -1
  85. package/dist/services/hardcopyService/hardcopyService.d.ts +3 -1
  86. package/dist/services/hardcopyService/hardcopyService.js +12 -3
  87. package/package.json +5 -1
  88. package/src/application/models/featureGates.ts +11 -8
  89. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +4 -59
  90. package/src/contexts/pageContext/pageContext.tsx +20 -41
  91. package/src/contexts/typesV3.ts +22 -4
  92. package/src/hooks/Intersection/useIntersection.ts +11 -0
  93. package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
  94. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
  95. package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
  96. package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
  97. package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
  98. package/src/services/amplitudeService/eventNames.ts +4 -0
  99. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +72 -158
  100. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +111 -13
  101. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +161 -490
  102. package/src/services/enviveConfigService/enviveConfigService.ts +51 -87
  103. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +74 -66
  104. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +0 -120
  105. package/src/services/featureFlagService/__tests__/getFeatureFlagOverrides.test.ts +54 -0
  106. package/src/services/featureFlagService/index.ts +19 -6
  107. package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
  108. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +16 -5
  109. package/src/services/hardcopyService/hardcopyService.ts +10 -2
  110. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +0 -42
  111. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +0 -41
  112. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +0 -55
@@ -4,17 +4,13 @@ import { Provider, useAtom } from 'jotai';
4
4
  import React from 'react';
5
5
  import CommerceApiClient from 'src/application/commerce-api';
6
6
  import Logger from 'src/application/logging/logger';
7
- import { FeatureGates, VariantTypeEnum } from 'src/application/models';
7
+ import { VariantTypeEnum } from 'src/application/models';
8
8
  import {
9
9
  UrlResolverResponse,
10
10
  pageUserEventAtom,
11
11
  pageVariantInfoAtom,
12
12
  urlResolverAtom,
13
13
  } from 'src/atoms/app/variant';
14
- import {
15
- FeatureFlagService,
16
- FeatureFlagServiceProvider,
17
- } from 'src/contexts/featureFlagServiceContext';
18
14
  import { mapUrlResolverResponseToVariantInfo } from '../mapping';
19
15
  import { PageProvider, usePage } from '../pageContext';
20
16
 
@@ -103,21 +99,6 @@ describe('PageProvider', () => {
103
99
  );
104
100
  };
105
101
 
106
- // Same as renderWithProviders but with the PDP variant-reset feature gate turned on, so the
107
- // client-side re-resolution behavior is exercised (it is gated off by default).
108
- const renderWithVariantResetEnabled = (children: React.ReactNode, previewMode = false) => {
109
- const featureFlagService = new FeatureFlagService([
110
- { name: FeatureGates.IsPdpVariantContextResetEnabled, value: true },
111
- ]);
112
- return render(
113
- <Provider>
114
- <FeatureFlagServiceProvider featureFlagService={featureFlagService}>
115
- <PageProvider previewMode={previewMode}>{children}</PageProvider>
116
- </FeatureFlagServiceProvider>
117
- </Provider>,
118
- );
119
- };
120
-
121
102
  describe('PageProvider Initialization', () => {
122
103
  it('should initialize with pageUrl from window.location.href', async () => {
123
104
  window.location.href = 'https://example.com/initial-page';
@@ -374,7 +355,7 @@ describe('PageProvider', () => {
374
355
  }) as UrlResolverResponse,
375
356
  );
376
357
 
377
- renderWithVariantResetEnabled(<NavComponent />, false);
358
+ renderWithProviders(<NavComponent />, false);
378
359
 
379
360
  // Initial resolution to product-a
380
361
  await waitFor(() => {
@@ -438,7 +419,7 @@ describe('PageProvider', () => {
438
419
  } as UrlResolverResponse;
439
420
  });
440
421
 
441
- renderWithVariantResetEnabled(<MultiNavComponent />, false);
422
+ renderWithProviders(<MultiNavComponent />, false);
442
423
 
443
424
  await waitFor(() => {
444
425
  expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
@@ -502,7 +483,7 @@ describe('PageProvider', () => {
502
483
  );
503
484
  };
504
485
 
505
- renderWithVariantResetEnabled(<OutOfOrderComp />, false);
486
+ renderWithProviders(<OutOfOrderComp />, false);
506
487
 
507
488
  // Initial resolution of /a is in flight (aPromise pending).
508
489
  await waitFor(() => {
@@ -548,42 +529,6 @@ describe('PageProvider', () => {
548
529
  expect(screen.getByTestId('is-loading').textContent).toBe('false');
549
530
  });
550
531
  });
551
-
552
- it('does NOT re-resolve on a URL change when the feature gate is off (default)', async () => {
553
- window.location.href = 'https://example.com/product/variant-a';
554
-
555
- mockResolveUrl.mockImplementation(
556
- async (url: string) =>
557
- ({
558
- variant_type: VariantTypeEnum.Pdp,
559
- specific_details: {
560
- pdp_attributes: { product_id: url.includes('variant-b') ? 'product-b' : 'product-a' },
561
- },
562
- ready: true,
563
- }) as UrlResolverResponse,
564
- );
565
-
566
- // No FeatureFlagServiceProvider -> gate resolves to false -> original behavior.
567
- renderWithProviders(<NavComponent />, false);
568
-
569
- await waitFor(() => {
570
- expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
571
- });
572
-
573
- await act(async () => {
574
- screen.getByTestId('navigate').click();
575
- });
576
- // Give any (unwanted) re-resolution time to happen.
577
- await act(async () => {
578
- await new Promise<void>(resolve => {
579
- setTimeout(resolve, 100);
580
- });
581
- });
582
-
583
- // The URL change must not have re-resolved; context stays on the original product.
584
- expect(mockResolveUrl).not.toHaveBeenCalledWith('https://example.com/product/variant-b');
585
- expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
586
- });
587
532
  });
588
533
 
589
534
  describe('Variant Info Mapping', () => {
@@ -17,8 +17,7 @@ import {
17
17
  variantInfoAtom,
18
18
  } from 'src/atoms/app/variant';
19
19
  import { useAtom, useAtomValue } from 'jotai';
20
- import { FeatureGates, VariantTypeEnum } from 'src/application/models';
21
- import { FeatureFlagServiceContext } from 'src/contexts/featureFlagServiceContext';
20
+ import { VariantTypeEnum } from 'src/application/models';
22
21
  import CommerceApiClient from 'src/application/commerce-api';
23
22
  import Logger from 'src/application/logging/logger';
24
23
  import { PageVisitCategory, UserEventCategory } from '@spiffy-ai/commerce-api-client';
@@ -60,16 +59,6 @@ export const PageProvider: React.FC<{
60
59
  // next navigation misreading it as "atoms were seeded externally" and skipping the API call.
61
60
  const hasResolvedOnceRef = useRef(false);
62
61
 
63
- // Gate: re-resolving the URL on a client-side change (PDP variant reset) is opt-in per org
64
- // via Statsig. When off, keep the original behavior (resolve once, then short-circuit).
65
- // Read via useContext (not useFeatureFlagService) so a bare PageProvider — e.g. in unit
66
- // tests — does not throw; absent provider or gate resolves to false.
67
- const featureFlagContext = useContext(FeatureFlagServiceContext);
68
- const isVariantResetEnabled =
69
- featureFlagContext?.featureFlagService?.isFeatureGateEnabled(
70
- FeatureGates.IsPdpVariantContextResetEnabled,
71
- ) ?? false;
72
-
73
62
  useEffect(() => {
74
63
  setPageUrl(window.location.href);
75
64
  }, []);
@@ -123,36 +112,27 @@ export const PageProvider: React.FC<{
123
112
 
124
113
  const cleansedUrl = pageUrl.toLowerCase().trim();
125
114
 
126
- if (!isVariantResetEnabled) {
127
- // Gate off: original behavior — resolve once, then short-circuit forever while the
128
- // variant/userEvent atoms are set (a client-side URL change never re-resolves).
129
- if (previewMode || userEvent || variantInfo) {
130
- setIsLoading(false);
131
- return;
132
- }
133
- } else {
134
- // Gate on: re-resolve when the URL changes so a PDP variant swap follows to the new
135
- // product.
136
- //
137
- // Already resolved this exact URL — guards against the effect re-running once the
138
- // resolver writes the variant/userEvent atoms (they are effect dependencies).
139
- if (lastResolvedUrlRef.current === cleansedUrl) {
140
- setIsLoading(false);
141
- return;
142
- }
143
- // In preview mode, or when the atoms were seeded externally (e.g. by storybook)
144
- // before we ourselves resolved anything, honor those atoms and skip the API call.
145
- // Once we have resolved at least once (`hasResolvedOnceRef`), a changed URL should
146
- // re-resolve even though the previous variant/userEvent atoms are set.
147
- if (previewMode || ((userEvent || variantInfo) && !hasResolvedOnceRef.current)) {
148
- hasResolvedOnceRef.current = true;
149
- lastResolvedUrlRef.current = cleansedUrl;
150
- setIsLoading(false);
151
- return;
152
- }
115
+ // Re-resolve when the URL changes so a client-side PDP variant swap follows to the new
116
+ // product.
117
+ //
118
+ // Already resolved this exact URL — guards against the effect re-running once the
119
+ // resolver writes the variant/userEvent atoms (they are effect dependencies).
120
+ if (lastResolvedUrlRef.current === cleansedUrl) {
121
+ setIsLoading(false);
122
+ return;
123
+ }
124
+ // In preview mode, or when the atoms were seeded externally (e.g. by storybook)
125
+ // before we ourselves resolved anything, honor those atoms and skip the API call.
126
+ // Once we have resolved at least once (`hasResolvedOnceRef`), a changed URL should
127
+ // re-resolve even though the previous variant/userEvent atoms are set.
128
+ if (previewMode || ((userEvent || variantInfo) && !hasResolvedOnceRef.current)) {
153
129
  hasResolvedOnceRef.current = true;
154
130
  lastResolvedUrlRef.current = cleansedUrl;
131
+ setIsLoading(false);
132
+ return;
155
133
  }
134
+ hasResolvedOnceRef.current = true;
135
+ lastResolvedUrlRef.current = cleansedUrl;
156
136
 
157
137
  const response =
158
138
  urlResolverResponse[cleansedUrl] ??
@@ -165,7 +145,7 @@ export const PageProvider: React.FC<{
165
145
  // open a window where isLoading=false while variantInfo/isSupported still belong to
166
146
  // the previous product (causing widget flashes and a fire-once success ref — see
167
147
  // Widgets.tsx — to latch onto the wrong product's state).
168
- if (isVariantResetEnabled && lastResolvedUrlRef.current !== cleansedUrl) {
148
+ if (lastResolvedUrlRef.current !== cleansedUrl) {
169
149
  return;
170
150
  }
171
151
 
@@ -205,7 +185,6 @@ export const PageProvider: React.FC<{
205
185
  variantInfo,
206
186
  previewMode,
207
187
  onUrlResolverNotReady,
208
- isVariantResetEnabled,
209
188
  ]);
210
189
 
211
190
  const { trackEvent } = useAmplitude();
@@ -396,6 +396,12 @@ export enum ProductCarouselLayoutV3 {
396
396
  GRID_MOBILE_HORIZONTAL_DESKTOP = 'grid-mobile-horizontal-desktop',
397
397
  }
398
398
 
399
+ export enum PartialViewModeV3 {
400
+ ENABLED = 'enabled',
401
+ MOBILE_ONLY = 'mobile-only',
402
+ DISABLED = 'disabled',
403
+ }
404
+
399
405
  interface FullPageSalesAgentWidgetV3Config extends BaseWidgetConfig<WidgetTypeV3.FullPageSalesAgentV3> {
400
406
  headerContainer?: string;
401
407
  autoHeight?: boolean;
@@ -408,12 +414,24 @@ interface FullPageSalesAgentWidgetV3Config extends BaseWidgetConfig<WidgetTypeV3
408
414
  suggestionButtonType?: PromptButtonVariant;
409
415
  productCarouselLayout?: ProductCarouselLayoutV3;
410
416
  /**
411
- * When true (or unset), asking a question opens the conversation in a
412
- * partial-height sheet that keeps the welcome overlay's product grid
413
- * visible above it. Set to false to fall back to the full-screen takeover.
414
- * Default true (undefined and true = partial view enabled).
417
+ * @deprecated Use `partialViewMode` instead. Kept only so a merchant who
418
+ * set this to `false` before `partialViewMode` existed doesn't silently
419
+ * revert to partial view enabled — see the fallback resolution in
420
+ * FullPageSalesAgent.tsx. Never written by the Hub UI anymore.
415
421
  */
416
422
  partialViewEnabled?: boolean;
423
+ /**
424
+ * Whether asking a question opens the conversation in a partial-height
425
+ * sheet that keeps the welcome overlay's product grid visible above it
426
+ * (mobile: a bottom sheet; desktop: a side panel), instead of a
427
+ * full-screen takeover.
428
+ * - `enabled` (default when unset): partial view on both mobile and desktop.
429
+ * - `mobile-only`: partial view on mobile; desktop still gets the
430
+ * full-screen takeover (the desktop panel doesn't suit every merchant's
431
+ * layout the way the mobile sheet does).
432
+ * - `disabled`: full-screen takeover on both.
433
+ */
434
+ partialViewMode?: PartialViewModeV3;
417
435
  }
418
436
 
419
437
  type WidgetConfigV3 =
@@ -40,12 +40,19 @@ export const useIntersection = (
40
40
  element: RefObject<HTMLElement>,
41
41
  rootMargin: string,
42
42
  logContext?: Record<string, unknown>,
43
+ // Called the first time visibility is forced by the reconcile fallback (observer stayed
44
+ // silent for an on-screen element). Lets callers record that this widget's visibility came
45
+ // from the fallback rather than the observer — the diagnostic for observer failures.
46
+ onFallbackEngaged?: () => void,
43
47
  ) => {
44
48
  const [isVisible, setIsVisible] = useState(false);
45
49
  const observerRef = useRef<IntersectionObserver | null>(null);
46
50
  const observedNodeRef = useRef<HTMLElement | null>(null);
47
51
  const logContextRef = useRef(logContext);
48
52
  logContextRef.current = logContext;
53
+ const onFallbackEngagedRef = useRef(onFallbackEngaged);
54
+ onFallbackEngagedRef.current = onFallbackEngaged;
55
+ const fallbackEngagedRef = useRef(false);
49
56
 
50
57
  // Runs after every render so it catches when element.current transitions from null to a
51
58
  // mounted DOM node, AND when the node under the ref is replaced. The replacement case is
@@ -112,6 +119,10 @@ export const useIntersection = (
112
119
  },
113
120
  );
114
121
  }
122
+ if (!fallbackEngagedRef.current) {
123
+ fallbackEngagedRef.current = true;
124
+ onFallbackEngagedRef.current?.();
125
+ }
115
126
  setIsVisible(true);
116
127
  }
117
128
  };
@@ -0,0 +1,64 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { RefObject } from 'react';
3
+ import { useTrackComponentVisibleEvent } from '../useTrackComponentVisibleEvent';
4
+
5
+ // Control the observer's isVisible and capture the fallback callback.
6
+ let mockIsVisible = false;
7
+ let capturedOnFallback: (() => void) | undefined;
8
+
9
+ vi.mock('src/hooks/Intersection/useIntersection', () => ({
10
+ useIntersection: (
11
+ _el: unknown,
12
+ _rm: unknown,
13
+ _ctx: unknown,
14
+ onFallback?: () => void,
15
+ ): boolean => {
16
+ capturedOnFallback = onFallback;
17
+ return mockIsVisible;
18
+ },
19
+ }));
20
+
21
+ vi.mock('src/contexts/amplitudeContext/amplitudeContext', () => ({
22
+ useAmplitude: () => ({ trackEvent: vi.fn(), isReady: true }),
23
+ }));
24
+
25
+ vi.mock('jotai', async (importOriginal: () => Promise<typeof import('jotai')>) => {
26
+ const actual = await importOriginal();
27
+ return { ...actual, useAtomValue: () => null };
28
+ });
29
+
30
+ const ref = { current: document.createElement('div') } as RefObject<HTMLElement>;
31
+
32
+ beforeEach(() => {
33
+ mockIsVisible = false;
34
+ capturedOnFallback = undefined;
35
+ });
36
+
37
+ describe('useTrackComponentVisibleEvent visibilitySource', () => {
38
+ it("stays 'pending' until visibility is established (so an eager load never claims 'observer')", () => {
39
+ const { result } = renderHook(() => useTrackComponentVisibleEvent(ref, {}));
40
+ expect(result.current.isVisible).toBe(false);
41
+ expect(result.current.visibilitySource).toBe('pending');
42
+ });
43
+
44
+ it("resolves to 'observer' when the observer reports the element visible", () => {
45
+ const { result, rerender } = renderHook(() => useTrackComponentVisibleEvent(ref, {}));
46
+ act(() => {
47
+ mockIsVisible = true;
48
+ rerender();
49
+ });
50
+ expect(result.current.visibilitySource).toBe('observer');
51
+ });
52
+
53
+ it("records 'fallback' when the reconcile fallback forces visibility (observer stayed silent)", () => {
54
+ const { result, rerender } = renderHook(() => useTrackComponentVisibleEvent(ref, {}));
55
+ act(() => {
56
+ // Fallback fires first (as it does in useIntersection, before its own setIsVisible)...
57
+ capturedOnFallback?.();
58
+ // ...then visibility flips true.
59
+ mockIsVisible = true;
60
+ rerender();
61
+ });
62
+ expect(result.current.visibilitySource).toBe('fallback');
63
+ });
64
+ });
@@ -1,5 +1,5 @@
1
1
  import { useAtomValue } from 'jotai';
2
- import { RefObject, useEffect, useRef } from 'react';
2
+ import { RefObject, useEffect, useRef, useState } from 'react';
3
3
  import Logger from 'src/application/logging/logger';
4
4
  import { pageVariantInfoAtom } from 'src/atoms/app';
5
5
  import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
@@ -34,10 +34,29 @@ export const useTrackComponentVisibleEvent = (
34
34
  eventProps?: Record<string, unknown>,
35
35
  rootMargin: string = '0px',
36
36
  enabled: boolean = true,
37
- ): { isVisible: boolean } => {
37
+ ): { isVisible: boolean; visibilitySource: 'observer' | 'fallback' | 'pending' } => {
38
+ // How visibility was established, for widget-load diagnostics:
39
+ // - 'pending' — not confirmed by either path yet. Stays this way for a `deferLoading: false`
40
+ // widget whose requests settle before the observer ever reports, so we don't
41
+ // falsely claim 'observer' for a load that never waited on visibility.
42
+ // - 'observer' — the IntersectionObserver reported the element visible.
43
+ // - 'fallback' — the reconcile fallback had to force it (observer stayed silent on-screen).
44
+ // State (not a ref) so consumers reliably re-render when the source resolves.
45
+ const [visibilitySource, setVisibilitySource] = useState<'observer' | 'fallback' | 'pending'>(
46
+ 'pending',
47
+ );
38
48
  // eventProps (widget_config_id, widget_type, …) double as log context so the hook's
39
49
  // visibility-fallback error identifies which widget it rescued.
40
- const isVisible = useIntersection(element, rootMargin, eventProps);
50
+ const isVisible = useIntersection(element, rootMargin, eventProps, () => {
51
+ // The observer stayed silent on-screen and the fallback forced visibility. This is recorded
52
+ // before useIntersection's own setIsVisible, so it wins over the 'observer' resolution below.
53
+ setVisibilitySource(prev => (prev === 'pending' ? 'fallback' : prev));
54
+ });
55
+ // Resolve 'pending' → 'observer' once the observer establishes visibility; a 'fallback'
56
+ // already recorded above is preserved.
57
+ useEffect(() => {
58
+ if (isVisible) setVisibilitySource(prev => (prev === 'pending' ? 'observer' : prev));
59
+ }, [isVisible]);
41
60
  const hasTrackedEvent = useRef(false);
42
61
  const { trackEvent } = useAmplitude();
43
62
  const variantInfo = useAtomValue(pageVariantInfoAtom);
@@ -87,5 +106,5 @@ export const useTrackComponentVisibleEvent = (
87
106
  });
88
107
  hasTrackedEvent.current = true;
89
108
  }, [enabled, isVisible, eventProps, trackEvent]);
90
- return { isVisible };
109
+ return { isVisible, visibilitySource };
91
110
  };
@@ -0,0 +1,161 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { WidgetTypeV3 } from 'src/contexts/typesV3';
3
+ import {
4
+ WIDGET_LOAD_STALL_MS,
5
+ WidgetLoadDiagnosticsInput,
6
+ useWidgetLoadDiagnostics,
7
+ } from '../useWidgetLoadDiagnostics';
8
+
9
+ const trackEvent = vi.fn();
10
+
11
+ vi.mock('src/contexts/amplitudeContext/amplitudeContext', () => ({
12
+ useAmplitude: () => ({ trackEvent, isReady: true }),
13
+ }));
14
+
15
+ // Keep jotai's atom() working; only stub useAtomValue so we don't need a Provider.
16
+ vi.mock('jotai', async (importOriginal: () => Promise<typeof import('jotai')>) => {
17
+ const actual = await importOriginal();
18
+ return { ...actual, useAtomValue: () => null };
19
+ });
20
+
21
+ const base: WidgetLoadDiagnosticsInput = {
22
+ enabled: true,
23
+ loadId: 'load-1',
24
+ widgetType: WidgetTypeV3.ProductCardV3,
25
+ widgetConfigId: 'cfg-1',
26
+ startLoading: false,
27
+ ownRequestsSettled: false,
28
+ hardcopyContent: undefined,
29
+ visibilitySource: 'observer',
30
+ pendingRequests: ['hardcopy'],
31
+ hardcopyErrorReason: undefined,
32
+ };
33
+
34
+ // Pull the eventProps of the diagnostics call whose `type` matches.
35
+ const propsOfType = (type: string): Record<string, unknown> | undefined =>
36
+ trackEvent.mock.calls
37
+ .map((c: [{ eventProps: Record<string, unknown> }]) => c[0].eventProps)
38
+ .find((p: Record<string, unknown>) => p.type === type);
39
+
40
+ beforeEach(() => {
41
+ trackEvent.mockClear();
42
+ });
43
+
44
+ describe('useWidgetLoadDiagnostics', () => {
45
+ it('emits nothing while disabled', () => {
46
+ renderHook(() => useWidgetLoadDiagnostics({ ...base, enabled: false, startLoading: true }));
47
+ expect(trackEvent).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('emits widget_mounted on mount regardless of visibility (the ungated baseline)', () => {
51
+ renderHook(() => useWidgetLoadDiagnostics({ ...base, startLoading: false }));
52
+ const mounted = propsOfType('widget_mounted');
53
+ expect(mounted).toBeDefined();
54
+ expect(mounted).toMatchObject({ load_id: 'load-1', widget_type: WidgetTypeV3.ProductCardV3 });
55
+ // No outcome/stall — it never became visible.
56
+ expect(propsOfType('widget_load_outcome')).toBeUndefined();
57
+ });
58
+
59
+ it('emits content_loaded when own requests settle with live api content', () => {
60
+ const { rerender } = renderHook(props => useWidgetLoadDiagnostics(props), {
61
+ initialProps: { ...base, startLoading: true },
62
+ });
63
+ rerender({
64
+ ...base,
65
+ startLoading: true,
66
+ ownRequestsSettled: true,
67
+ hardcopyContent: { responseId: 'r', language: 'en', values: {}, servedFrom: 'api' },
68
+ pendingRequests: [],
69
+ });
70
+ const outcome = propsOfType('widget_load_outcome');
71
+ expect(outcome).toMatchObject({ outcome: 'content_loaded', visibility_source: 'observer' });
72
+ });
73
+
74
+ it('emits static_fallback with the fallback reason', () => {
75
+ const { rerender } = renderHook(props => useWidgetLoadDiagnostics(props), {
76
+ initialProps: { ...base, startLoading: true },
77
+ });
78
+ rerender({
79
+ ...base,
80
+ startLoading: true,
81
+ ownRequestsSettled: true,
82
+ hardcopyContent: {
83
+ responseId: 'r',
84
+ language: 'en',
85
+ values: {},
86
+ servedFrom: 'static',
87
+ fallbackReason: 'timeout',
88
+ },
89
+ });
90
+ expect(propsOfType('widget_load_outcome')).toMatchObject({
91
+ outcome: 'static_fallback',
92
+ reason: 'timeout',
93
+ });
94
+ });
95
+
96
+ it('emits error when the load settles without content', () => {
97
+ const { rerender } = renderHook(props => useWidgetLoadDiagnostics(props), {
98
+ initialProps: { ...base, startLoading: true },
99
+ });
100
+ rerender({
101
+ ...base,
102
+ startLoading: true,
103
+ ownRequestsSettled: true,
104
+ hardcopyContent: undefined,
105
+ hardcopyErrorReason: 'hardcopy_error',
106
+ });
107
+ expect(propsOfType('widget_load_outcome')).toMatchObject({
108
+ outcome: 'error',
109
+ reason: 'hardcopy_error',
110
+ });
111
+ });
112
+
113
+ it('carries visibility_source=fallback through the outcome', () => {
114
+ const { rerender } = renderHook(props => useWidgetLoadDiagnostics(props), {
115
+ initialProps: { ...base, startLoading: true, visibilitySource: 'fallback' as const },
116
+ });
117
+ rerender({
118
+ ...base,
119
+ startLoading: true,
120
+ visibilitySource: 'fallback',
121
+ ownRequestsSettled: true,
122
+ hardcopyContent: { responseId: 'r', language: 'en', values: {}, servedFrom: 'api' },
123
+ });
124
+ expect(propsOfType('widget_load_outcome')).toMatchObject({ visibility_source: 'fallback' });
125
+ });
126
+
127
+ describe('stall (abandonment-safe)', () => {
128
+ beforeEach(() => vi.useFakeTimers());
129
+ afterEach(() => vi.useRealTimers());
130
+
131
+ it('emits widget_load_stalled if still loading at the threshold', () => {
132
+ renderHook(() => useWidgetLoadDiagnostics({ ...base, startLoading: true }));
133
+ act(() => {
134
+ vi.advanceTimersByTime(WIDGET_LOAD_STALL_MS);
135
+ });
136
+ expect(propsOfType('widget_load_stalled')).toMatchObject({ pending_requests: ['hardcopy'] });
137
+ });
138
+
139
+ it('does NOT emit a stall when the widget unmounts first (navigated away)', () => {
140
+ const { unmount } = renderHook(() =>
141
+ useWidgetLoadDiagnostics({ ...base, startLoading: true }),
142
+ );
143
+ unmount();
144
+ act(() => {
145
+ vi.advanceTimersByTime(WIDGET_LOAD_STALL_MS * 2);
146
+ });
147
+ expect(propsOfType('widget_load_stalled')).toBeUndefined();
148
+ });
149
+
150
+ it('does NOT emit a stall once the load has settled', () => {
151
+ const { rerender } = renderHook(props => useWidgetLoadDiagnostics(props), {
152
+ initialProps: { ...base, startLoading: true },
153
+ });
154
+ rerender({ ...base, startLoading: true, ownRequestsSettled: true });
155
+ act(() => {
156
+ vi.advanceTimersByTime(WIDGET_LOAD_STALL_MS * 2);
157
+ });
158
+ expect(propsOfType('widget_load_stalled')).toBeUndefined();
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,5 @@
1
+ export { useWidgetLoadDiagnostics, WIDGET_LOAD_STALL_MS } from './useWidgetLoadDiagnostics';
2
+ export type {
3
+ WidgetLoadDiagnosticsInput,
4
+ WidgetVisibilitySource,
5
+ } from './useWidgetLoadDiagnostics';