@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.
- package/dist/application/models/featureGates.cjs +2 -2
- package/dist/application/models/featureGates.d.cts +2 -2
- package/dist/application/models/featureGates.d.ts +2 -2
- package/dist/application/models/featureGates.js +2 -2
- package/dist/application/utils/elementObserver.d.cts +2 -2
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +1 -1
- package/dist/atoms/app/variant.d.ts +6 -6
- package/dist/atoms/chat/chatState.d.cts +19 -19
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +3 -3
- package/dist/atoms/chat/index.d.ts +3 -3
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.ts +7 -7
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/suggestions.d.ts +3 -3
- package/dist/atoms/envive/enviveConfig.d.cts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/search/utils.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/pageContext/pageContext.cjs +12 -24
- package/dist/contexts/pageContext/pageContext.js +12 -24
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.cjs +8 -1
- package/dist/contexts/typesV3.d.cts +23 -6
- package/dist/contexts/typesV3.d.ts +23 -6
- package/dist/contexts/typesV3.js +8 -2
- package/dist/hooks/Intersection/useIntersection.cjs +9 -2
- package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
- package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
- package/dist/hooks/Intersection/useIntersection.js +9 -2
- package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
- package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
- package/dist/hooks/utils.d.cts +1 -1
- package/dist/hooks/utils.d.ts +1 -1
- package/dist/services/amplitudeService/eventNames.cjs +2 -1
- package/dist/services/amplitudeService/eventNames.d.cts +2 -1
- package/dist/services/amplitudeService/eventNames.d.ts +2 -1
- package/dist/services/amplitudeService/eventNames.js +2 -1
- package/dist/services/enviveConfigService/enviveConfigService.cjs +21 -35
- package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -2
- package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -2
- package/dist/services/enviveConfigService/enviveConfigService.js +21 -35
- package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +15 -16
- package/dist/services/enviveConfigService/fetchBootstrapConfig.js +15 -16
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +1 -69
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -69
- package/dist/services/featureFlagService/index.cjs +7 -5
- package/dist/services/featureFlagService/index.d.cts +2 -1
- package/dist/services/featureFlagService/index.d.ts +2 -1
- package/dist/services/featureFlagService/index.js +7 -5
- package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
- package/dist/services/hardcopyService/hardcopyService.cjs +12 -3
- package/dist/services/hardcopyService/hardcopyService.d.cts +3 -1
- package/dist/services/hardcopyService/hardcopyService.d.ts +3 -1
- package/dist/services/hardcopyService/hardcopyService.js +12 -3
- package/package.json +5 -1
- package/src/application/models/featureGates.ts +11 -8
- package/src/contexts/pageContext/__tests__/pageContext.test.tsx +4 -59
- package/src/contexts/pageContext/pageContext.tsx +20 -41
- package/src/contexts/typesV3.ts +22 -4
- package/src/hooks/Intersection/useIntersection.ts +11 -0
- package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
- package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
- package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
- package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
- package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
- package/src/services/amplitudeService/eventNames.ts +4 -0
- package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +72 -158
- package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +111 -13
- package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +161 -490
- package/src/services/enviveConfigService/enviveConfigService.ts +51 -87
- package/src/services/enviveConfigService/fetchBootstrapConfig.ts +74 -66
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +0 -120
- package/src/services/featureFlagService/__tests__/getFeatureFlagOverrides.test.ts +54 -0
- package/src/services/featureFlagService/index.ts +19 -6
- package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +16 -5
- package/src/services/hardcopyService/hardcopyService.ts +10 -2
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +0 -42
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +0 -41
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
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();
|
package/src/contexts/typesV3.ts
CHANGED
|
@@ -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
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
+
});
|