@envive-ai/react-hooks 0.3.62 → 0.3.64
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 -1
- package/dist/application/models/featureGates.d.cts +2 -1
- package/dist/application/models/featureGates.d.ts +2 -1
- package/dist/application/models/featureGates.js +2 -1
- package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
- package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
- package/dist/atoms/app/variant.d.cts +6 -6
- package/dist/atoms/chat/chatState.d.cts +1 -1
- 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 +2 -2
- package/dist/atoms/chat/index.d.cts +2 -2
- 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.cts +7 -7
- package/dist/atoms/chat/messageQueue.d.ts +6 -6
- 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 +3 -3
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +3 -3
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +13 -13
- package/dist/atoms/envive/enviveConfig.d.ts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/globalSearch/globalSearch.d.ts +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.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/utils.d.ts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- 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/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -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/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 +49 -3
- package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
- package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
- package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
- package/dist/services/hardcopyService/hardcopyService.cjs +31 -6
- package/dist/services/hardcopyService/hardcopyService.d.cts +5 -2
- package/dist/services/hardcopyService/hardcopyService.d.ts +5 -2
- package/dist/services/hardcopyService/hardcopyService.js +31 -7
- package/dist/services/hardcopyService/index.cjs +1 -0
- package/dist/services/hardcopyService/index.d.cts +2 -2
- package/dist/services/hardcopyService/index.d.ts +2 -2
- package/dist/services/hardcopyService/index.js +2 -2
- package/dist/services/urlResolverWarmupService/index.cjs +3 -3
- package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
- package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
- package/dist/services/urlResolverWarmupService/index.js +3 -3
- package/package.json +5 -1
- package/src/application/models/featureGates.ts +6 -0
- 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 +226 -0
- package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
- package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
- package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
- package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +73 -3
- package/src/services/hardcopyService/hardcopyService.ts +55 -5
- package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
- package/src/services/urlResolverWarmupService/index.ts +18 -1
|
@@ -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,178 @@
|
|
|
1
|
+
import { useAtomValue } from 'jotai';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { pageVariantInfoAtom } from 'src/atoms/app';
|
|
4
|
+
import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
5
|
+
import { HardcopyResponse } from 'src/contexts/hardcopyContext';
|
|
6
|
+
import { PageVariantInfo } from 'src/contexts/pageContext/types';
|
|
7
|
+
import { WidgetTypeV3 } from 'src/contexts/typesV3';
|
|
8
|
+
import { extractPageContext } from 'src/hooks/WidgetInteraction/utils';
|
|
9
|
+
import { EnviveMetricsEventName } from 'src/services/amplitudeService/amplitudeService';
|
|
10
|
+
|
|
11
|
+
// Fired if a widget is still loading this long after it became visible. Kept at/just above
|
|
12
|
+
// the hardcopy timeout (HARDCOPY_TIMEOUT_MS, 4s) so a normal timeout → static-fallback settles
|
|
13
|
+
// first and does NOT trip a stall; a stall here is a genuine anomaly (e.g. a config hang).
|
|
14
|
+
// Mirrors WIDGET_LOADING_WATCHDOG_MS (the console-only watchdog in withBaseWidget).
|
|
15
|
+
export const WIDGET_LOAD_STALL_MS = 5000;
|
|
16
|
+
|
|
17
|
+
// 'pending' = visibility not confirmed by either path when the event fired (e.g. a
|
|
18
|
+
// deferLoading:false widget that loaded eagerly, before the observer reported).
|
|
19
|
+
export type WidgetVisibilitySource = 'observer' | 'fallback' | 'pending';
|
|
20
|
+
|
|
21
|
+
export interface WidgetLoadDiagnosticsInput {
|
|
22
|
+
/** Gate. No events are emitted unless true. On by default; a Statsig kill switch can disable it. */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Correlates this widget's mount / rendered / outcome / stall events for a single load. */
|
|
25
|
+
loadId: string;
|
|
26
|
+
widgetType: WidgetTypeV3;
|
|
27
|
+
widgetConfigId?: string;
|
|
28
|
+
/** The widget became visible and started loading its own requests. */
|
|
29
|
+
startLoading: boolean;
|
|
30
|
+
/** This widget's own hardcopy / widgetConfig / uiConfig requests have all settled. */
|
|
31
|
+
ownRequestsSettled: boolean;
|
|
32
|
+
/** Present once hardcopy resolves; carries `servedFrom` (content_loaded vs static_fallback). */
|
|
33
|
+
hardcopyContent?: HardcopyResponse;
|
|
34
|
+
/** Whether visibility came from the observer or the reconcile fallback. */
|
|
35
|
+
visibilitySource: WidgetVisibilitySource;
|
|
36
|
+
/** Names of this widget's still-unsettled requests, reported on a stall. */
|
|
37
|
+
pendingRequests: string[];
|
|
38
|
+
/** Set when this widget's own hardcopy request rejected (no content — not a static fallback). */
|
|
39
|
+
hardcopyErrorReason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const connectionType = (): string | undefined => {
|
|
43
|
+
try {
|
|
44
|
+
// Network Information API — Chromium/Android only, undefined on iOS Safari (our key population).
|
|
45
|
+
return (navigator as Navigator & { connection?: { effectiveType?: string } }).connection
|
|
46
|
+
?.effectiveType;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const now = (): number => {
|
|
53
|
+
try {
|
|
54
|
+
return typeof window !== 'undefined' && window.performance?.now
|
|
55
|
+
? window.performance.now()
|
|
56
|
+
: Date.now();
|
|
57
|
+
} catch {
|
|
58
|
+
return Date.now();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emits the layered `[Envive] Diagnostics` widget-load net so eng can measure not just what
|
|
64
|
+
* loads, but the negative space between layers — the failures that fire nothing today:
|
|
65
|
+
*
|
|
66
|
+
* widget_mounted (ungated) → [Envive] Widget Rendered (visible) → widget_load_outcome
|
|
67
|
+
*
|
|
68
|
+
* - `widget_mounted` fires on mount regardless of visibility, so a widget that mounts but never
|
|
69
|
+
* becomes visible (the suspected infinite-load crux) shows up as a *mounted with no rendered*
|
|
70
|
+
* gap instead of being invisible to analytics entirely.
|
|
71
|
+
* - `widget_load_outcome` fires once the widget's own requests settle
|
|
72
|
+
* (content_loaded / static_fallback / error).
|
|
73
|
+
* - `widget_load_stalled` fires if it's still loading at WIDGET_LOAD_STALL_MS AND still mounted.
|
|
74
|
+
* The timer is cleared on unmount, so a shopper who navigates away first never trips it —
|
|
75
|
+
* "stalled with no later outcome" is therefore a clean stuck signal, undiluted by abandonment.
|
|
76
|
+
*
|
|
77
|
+
* `load_id` correlates all of the above (and is threaded onto `[Envive] Widget Rendered`) so
|
|
78
|
+
* the gaps are joinable per load.
|
|
79
|
+
*/
|
|
80
|
+
export const useWidgetLoadDiagnostics = (input: WidgetLoadDiagnosticsInput): void => {
|
|
81
|
+
const {
|
|
82
|
+
enabled,
|
|
83
|
+
loadId,
|
|
84
|
+
widgetType,
|
|
85
|
+
widgetConfigId,
|
|
86
|
+
startLoading,
|
|
87
|
+
ownRequestsSettled,
|
|
88
|
+
hardcopyContent,
|
|
89
|
+
} = input;
|
|
90
|
+
|
|
91
|
+
const { trackEvent } = useAmplitude();
|
|
92
|
+
const variantInfo = useAtomValue(pageVariantInfoAtom);
|
|
93
|
+
|
|
94
|
+
// Latest input, read inside the deferred stall/outcome callbacks (pendingRequests and
|
|
95
|
+
// visibilitySource churn as requests settle / the fallback engages).
|
|
96
|
+
const inputRef = useRef(input);
|
|
97
|
+
inputRef.current = input;
|
|
98
|
+
|
|
99
|
+
const emit = (props: Record<string, unknown>) => {
|
|
100
|
+
trackEvent({
|
|
101
|
+
eventName: EnviveMetricsEventName.Diagnostics,
|
|
102
|
+
eventProps: {
|
|
103
|
+
load_id: loadId,
|
|
104
|
+
widget_type: widgetType,
|
|
105
|
+
widget_config_id: widgetConfigId,
|
|
106
|
+
connection_type: connectionType(),
|
|
107
|
+
context: variantInfo ? extractPageContext(variantInfo as PageVariantInfo) : null,
|
|
108
|
+
...props,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Layer 0 — mount. Ungated by visibility: the baseline that fires even when the widget never
|
|
114
|
+
// becomes visible, so never-visible failures are a measurable gap rather than silence.
|
|
115
|
+
const mountFiredRef = useRef(false);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!enabled || mountFiredRef.current) return;
|
|
118
|
+
mountFiredRef.current = true;
|
|
119
|
+
emit({ type: 'widget_mounted' });
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
121
|
+
}, [enabled]);
|
|
122
|
+
|
|
123
|
+
// t0 — when the widget became visible / started loading.
|
|
124
|
+
const visibleAtRef = useRef<number | null>(null);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (startLoading && visibleAtRef.current === null) visibleAtRef.current = now();
|
|
127
|
+
}, [startLoading]);
|
|
128
|
+
|
|
129
|
+
// Layer 2 — terminal outcome, once, when this widget's own requests settle.
|
|
130
|
+
const outcomeFiredRef = useRef(false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!enabled || !startLoading || !ownRequestsSettled || outcomeFiredRef.current) return;
|
|
133
|
+
outcomeFiredRef.current = true;
|
|
134
|
+
|
|
135
|
+
const elapsed_ms =
|
|
136
|
+
visibleAtRef.current !== null ? Math.round(now() - visibleAtRef.current) : undefined;
|
|
137
|
+
|
|
138
|
+
let outcome: string;
|
|
139
|
+
let reason: string | undefined;
|
|
140
|
+
if (!hardcopyContent) {
|
|
141
|
+
outcome = 'error';
|
|
142
|
+
reason = inputRef.current.hardcopyErrorReason ?? 'no_content';
|
|
143
|
+
} else if (hardcopyContent.servedFrom === 'static') {
|
|
144
|
+
outcome = 'static_fallback';
|
|
145
|
+
reason = hardcopyContent.fallbackReason;
|
|
146
|
+
} else {
|
|
147
|
+
outcome = 'content_loaded';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
emit({
|
|
151
|
+
type: 'widget_load_outcome',
|
|
152
|
+
outcome,
|
|
153
|
+
reason,
|
|
154
|
+
elapsed_ms,
|
|
155
|
+
visibility_source: inputRef.current.visibilitySource,
|
|
156
|
+
});
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [enabled, startLoading, ownRequestsSettled, hardcopyContent]);
|
|
159
|
+
|
|
160
|
+
// Layer 1.5 — stall. Only arms while loading; cleared on unmount, so abandonment never trips it.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!enabled || !startLoading || ownRequestsSettled) return undefined;
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
const elapsed_ms =
|
|
165
|
+
visibleAtRef.current !== null
|
|
166
|
+
? Math.round(now() - visibleAtRef.current)
|
|
167
|
+
: WIDGET_LOAD_STALL_MS;
|
|
168
|
+
emit({
|
|
169
|
+
type: 'widget_load_stalled',
|
|
170
|
+
elapsed_ms,
|
|
171
|
+
pending_requests: inputRef.current.pendingRequests,
|
|
172
|
+
visibility_source: inputRef.current.visibilitySource,
|
|
173
|
+
});
|
|
174
|
+
}, WIDGET_LOAD_STALL_MS);
|
|
175
|
+
return () => clearTimeout(timer);
|
|
176
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
+
}, [enabled, startLoading, ownRequestsSettled]);
|
|
178
|
+
};
|
|
@@ -48,4 +48,8 @@ export enum EnviveMetricsEventName {
|
|
|
48
48
|
WidgetTextRequest = 'Widget Text Request',
|
|
49
49
|
WidgetTextResponse = 'Widget Text Response',
|
|
50
50
|
WidgetTextClicked = 'Widget Text Clicked',
|
|
51
|
+
// Operational diagnostics for widget loading (see useWidgetLoadDiagnostics). A single event
|
|
52
|
+
// with a `type` discriminator (widget_mounted | widget_load_outcome | widget_load_stalled)
|
|
53
|
+
// so eng can measure the layered load net and, crucially, the negative space between layers.
|
|
54
|
+
Diagnostics = 'Diagnostics',
|
|
51
55
|
}
|
|
@@ -30,6 +30,76 @@ function makeOkConfigFetch() {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// getWidgetConfig response with the use_unified_config gate ON (plus a real
|
|
34
|
+
// version pin) — the trigger for the /v1/session/bootstrap upgrade.
|
|
35
|
+
function makeGateOnConfigFetch() {
|
|
36
|
+
mockFetch.mockResolvedValueOnce({
|
|
37
|
+
ok: true,
|
|
38
|
+
json: () =>
|
|
39
|
+
Promise.resolve({
|
|
40
|
+
data: {
|
|
41
|
+
me: {
|
|
42
|
+
org: {
|
|
43
|
+
id: 'org-1',
|
|
44
|
+
shortName: 'test-org',
|
|
45
|
+
displayName: 'Test Org',
|
|
46
|
+
domain: 'test.com',
|
|
47
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
48
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
49
|
+
},
|
|
50
|
+
getWidgetConfig: {
|
|
51
|
+
productsConfig: {},
|
|
52
|
+
gates: [{ name: 'use_unified_config', value: true }],
|
|
53
|
+
experimentAssignments: [],
|
|
54
|
+
resolution: { baseVersion: '1.0.0', baseVersionSource: 'merchant_layer' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// /v1/session/bootstrap response (REST snake_case). Distinct org id so tests can
|
|
63
|
+
// assert the config actually came from bootstrap, not the getWidgetConfig call.
|
|
64
|
+
function makeBootstrapFetch() {
|
|
65
|
+
mockFetch.mockResolvedValueOnce({
|
|
66
|
+
ok: true,
|
|
67
|
+
json: () =>
|
|
68
|
+
Promise.resolve({
|
|
69
|
+
org_config: {
|
|
70
|
+
org: {
|
|
71
|
+
org: {
|
|
72
|
+
id: 'org-boot',
|
|
73
|
+
short_name: 'boot-org',
|
|
74
|
+
display_name: 'Boot Org',
|
|
75
|
+
domain: 'boot.com',
|
|
76
|
+
status: 'active',
|
|
77
|
+
created_at: '2024-01-01T00:00:00.000Z',
|
|
78
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
79
|
+
},
|
|
80
|
+
settings: {},
|
|
81
|
+
},
|
|
82
|
+
gates: [{ name: 'use_unified_config', value: true, group_name: 'Treatment' }],
|
|
83
|
+
resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
|
|
84
|
+
},
|
|
85
|
+
widget_config: {
|
|
86
|
+
products_config: {},
|
|
87
|
+
// Assignments come from widget_config (matches getWidgetConfig, ungated).
|
|
88
|
+
experiment_assignments: [
|
|
89
|
+
{
|
|
90
|
+
layer_name: 'ml',
|
|
91
|
+
namespace: 'ml',
|
|
92
|
+
allocated_experiment_name: 'exp_a',
|
|
93
|
+
group_name: 'A',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
|
|
97
|
+
},
|
|
98
|
+
url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
function makeService(
|
|
34
104
|
overrides: Partial<ConstructorParameters<typeof EnviveConfigService>[0]> = {},
|
|
35
105
|
) {
|
|
@@ -182,3 +252,159 @@ describe('EnviveConfigService caching', () => {
|
|
|
182
252
|
expect(result.org.org.id).toBe('org-1');
|
|
183
253
|
});
|
|
184
254
|
});
|
|
255
|
+
|
|
256
|
+
describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
|
|
257
|
+
// The injection bundle supplies env + contextSource; without them the branch
|
|
258
|
+
// is skipped entirely (see the "non-bundle caller" case below).
|
|
259
|
+
const bootstrapService = () => makeService({ env: 'dev', contextSource: 'app' });
|
|
260
|
+
|
|
261
|
+
it('stays on getWidgetConfig (no bootstrap) when the gate is off', async () => {
|
|
262
|
+
makeOkConfigFetch(); // gates: []
|
|
263
|
+
|
|
264
|
+
const service = bootstrapService();
|
|
265
|
+
const result = await service.getEnviveConfig();
|
|
266
|
+
|
|
267
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(mockFetch.mock.calls[0][0]).toContain('/v1/graphql');
|
|
269
|
+
expect(result.org.org.id).toBe('org-1');
|
|
270
|
+
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('calls /v1/session/bootstrap and uses ITS config when the gate is on', async () => {
|
|
274
|
+
makeGateOnConfigFetch();
|
|
275
|
+
makeBootstrapFetch();
|
|
276
|
+
|
|
277
|
+
const service = bootstrapService();
|
|
278
|
+
const result = await service.getEnviveConfig();
|
|
279
|
+
|
|
280
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
281
|
+
expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
|
|
282
|
+
// config is bootstrap's, not the getWidgetConfig call used to read the gate.
|
|
283
|
+
expect(result.org.org.id).toBe('org-boot');
|
|
284
|
+
expect(result.org.org.short_name).toBe('boot-org');
|
|
285
|
+
// gates: snake group_name mapped to camel groupName.
|
|
286
|
+
expect(result.gates).toContainEqual({
|
|
287
|
+
name: 'use_unified_config',
|
|
288
|
+
value: true,
|
|
289
|
+
groupName: 'Treatment',
|
|
290
|
+
});
|
|
291
|
+
// experiment_assignments pass through (already snake).
|
|
292
|
+
expect(result.experiment_assignments).toEqual([
|
|
293
|
+
{ layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
|
|
294
|
+
]);
|
|
295
|
+
// resolution mapped snake -> camel.
|
|
296
|
+
expect(result.orgConfig?.resolution).toEqual({
|
|
297
|
+
baseVersion: '1.0.0',
|
|
298
|
+
baseVersionSource: 'merchant_layer',
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('stashes bootstrap url_resolving (paired with its URL) for the warm-up to consume once', async () => {
|
|
303
|
+
makeGateOnConfigFetch();
|
|
304
|
+
makeBootstrapFetch();
|
|
305
|
+
|
|
306
|
+
const service = bootstrapService();
|
|
307
|
+
await service.getEnviveConfig();
|
|
308
|
+
|
|
309
|
+
const taken = service.takeBootstrapUrlResolving();
|
|
310
|
+
expect(taken?.response).toEqual({ variant_type: 'pdp', specific_details: {}, ready: true });
|
|
311
|
+
// paired with the URL bootstrap resolved (the current, cleansed href) so the
|
|
312
|
+
// warm-up can guard against a SPA nav before seeding.
|
|
313
|
+
expect(taken?.url).toBe(window.location.href.toLowerCase().trim());
|
|
314
|
+
// handed off exactly once.
|
|
315
|
+
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('sends url_resolving context + gate names + version pin in the bootstrap body', async () => {
|
|
319
|
+
makeGateOnConfigFetch();
|
|
320
|
+
makeBootstrapFetch();
|
|
321
|
+
|
|
322
|
+
await bootstrapService().getEnviveConfig();
|
|
323
|
+
|
|
324
|
+
const [, options] = mockFetch.mock.calls[1];
|
|
325
|
+
const body = JSON.parse(options.body as string);
|
|
326
|
+
expect(body.include_feature_gates).toContain('use_unified_config');
|
|
327
|
+
// Scopes org_config.configs so bootstrap doesn't dump internal configs to the browser.
|
|
328
|
+
expect(body.namespace).toBe('spiffy-react-components');
|
|
329
|
+
expect(body.url_resolving.context).toMatchObject({
|
|
330
|
+
org_id: 'org-1',
|
|
331
|
+
org_short_name: 'test-org',
|
|
332
|
+
source: 'app',
|
|
333
|
+
env: 'dev',
|
|
334
|
+
});
|
|
335
|
+
// The version comes ONLY from the URL query param (none set here) — never from
|
|
336
|
+
// getWidgetConfig's resolution, so bootstrap doesn't depend on the discarded
|
|
337
|
+
// getWidgetConfig response.
|
|
338
|
+
expect(body.url_resolving.org_base_config_version).toBeUndefined();
|
|
339
|
+
expect(body.url_resolving.override_config_version).toBeUndefined();
|
|
340
|
+
// override-aware gates (FeatureFlagService.getFeatureFlags()) forwarded so
|
|
341
|
+
// client gate overrides reach page-variant resolution.
|
|
342
|
+
expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
|
|
343
|
+
// the persistent chat session id is threaded (not an empty string).
|
|
344
|
+
expect(body.url_resolving.context.chat_id).toBeTruthy();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('reflects a ?<gate>=true override in the bootstrap feature_gates it sends', async () => {
|
|
348
|
+
// Force a page-variant gate the server did NOT return — it must still reach
|
|
349
|
+
// bootstrap's url_resolving via getFeatureFlags()'s override precedence.
|
|
350
|
+
window.history.replaceState({}, '', '/?use_unified_config=true&is_empty_div_plp_enabled=true');
|
|
351
|
+
makeOkConfigFetch(); // gates: [] — the ?use_unified_config=true override drives the bootstrap path
|
|
352
|
+
makeBootstrapFetch();
|
|
353
|
+
|
|
354
|
+
await bootstrapService().getEnviveConfig();
|
|
355
|
+
|
|
356
|
+
const body = JSON.parse(mockFetch.mock.calls[1][1].body as string);
|
|
357
|
+
expect(body.url_resolving.feature_gates.is_empty_div_plp_enabled).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('skips bootstrap for non-bundle callers (no env/contextSource) even when the gate is on', async () => {
|
|
361
|
+
makeGateOnConfigFetch();
|
|
362
|
+
|
|
363
|
+
const result = await makeService().getEnviveConfig(); // no env/contextSource
|
|
364
|
+
|
|
365
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
366
|
+
expect(result.org.org.id).toBe('org-1');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('falls back to the getWidgetConfig config when bootstrap fails', async () => {
|
|
370
|
+
makeGateOnConfigFetch();
|
|
371
|
+
mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
|
|
372
|
+
|
|
373
|
+
const service = bootstrapService();
|
|
374
|
+
const result = await service.getEnviveConfig();
|
|
375
|
+
|
|
376
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
377
|
+
// degraded to the proven path rather than aborting.
|
|
378
|
+
expect(result.org.org.id).toBe('org-1');
|
|
379
|
+
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('falls back to getWidgetConfig config when bootstrap returns a malformed 2xx payload', async () => {
|
|
383
|
+
makeGateOnConfigFetch();
|
|
384
|
+
// 2xx but missing required sections — must NOT replace the known-good config
|
|
385
|
+
// with empty gates / mock products (which would disable the client session).
|
|
386
|
+
mockFetch.mockResolvedValueOnce({
|
|
387
|
+
ok: true,
|
|
388
|
+
json: () => Promise.resolve({ org_config: {}, widget_config: null }),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const service = bootstrapService();
|
|
392
|
+
const result = await service.getEnviveConfig();
|
|
393
|
+
|
|
394
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
395
|
+
expect(result.org.org.id).toBe('org-1'); // getWidgetConfig's, not bootstrap's empty payload
|
|
396
|
+
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('honors a ?use_unified_config=true override when the gate itself is off', async () => {
|
|
400
|
+
window.history.replaceState({}, '', '/?use_unified_config=true');
|
|
401
|
+
makeOkConfigFetch(); // gates: [] — the override, not the gate, forces bootstrap
|
|
402
|
+
makeBootstrapFetch();
|
|
403
|
+
|
|
404
|
+
const result = await bootstrapService().getEnviveConfig();
|
|
405
|
+
|
|
406
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
407
|
+
expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
|
|
408
|
+
expect(result.org.org.id).toBe('org-boot');
|
|
409
|
+
});
|
|
410
|
+
});
|