@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.
Files changed (105) hide show
  1. package/dist/application/models/featureGates.cjs +2 -1
  2. package/dist/application/models/featureGates.d.cts +2 -1
  3. package/dist/application/models/featureGates.d.ts +2 -1
  4. package/dist/application/models/featureGates.js +2 -1
  5. package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
  6. package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
  7. package/dist/atoms/app/variant.d.cts +6 -6
  8. package/dist/atoms/chat/chatState.d.cts +1 -1
  9. package/dist/atoms/chat/chatState.d.ts +19 -19
  10. package/dist/atoms/chat/form.d.cts +2 -2
  11. package/dist/atoms/chat/form.d.ts +2 -2
  12. package/dist/atoms/chat/index.d.cts +2 -2
  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.cts +7 -7
  17. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  18. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  19. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  20. package/dist/atoms/chat/renderedWidgetRefs.d.cts +3 -3
  21. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  22. package/dist/atoms/chat/replies.d.cts +3 -3
  23. package/dist/atoms/chat/replies.d.ts +3 -3
  24. package/dist/atoms/chat/suggestions.d.cts +3 -3
  25. package/dist/atoms/chat/suggestions.d.ts +2 -2
  26. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  27. package/dist/atoms/envive/enviveConfig.d.ts +14 -14
  28. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  29. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  30. package/dist/atoms/org/customerService.d.cts +6 -6
  31. package/dist/atoms/org/customerService.d.ts +6 -6
  32. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  33. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  34. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  35. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  36. package/dist/atoms/search/chatSearch.d.ts +17 -17
  37. package/dist/atoms/search/searchAPI.d.ts +13 -13
  38. package/dist/atoms/search/utils.d.ts +1 -1
  39. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  40. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  41. package/dist/contexts/types.d.cts +1 -1
  42. package/dist/contexts/types.d.ts +1 -1
  43. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  44. package/dist/hooks/Intersection/useIntersection.cjs +9 -2
  45. package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
  46. package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
  47. package/dist/hooks/Intersection/useIntersection.js +9 -2
  48. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  49. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
  50. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
  51. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
  52. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
  53. package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
  54. package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
  55. package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
  56. package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
  57. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
  58. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
  59. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
  60. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
  61. package/dist/services/amplitudeService/eventNames.cjs +2 -1
  62. package/dist/services/amplitudeService/eventNames.d.cts +2 -1
  63. package/dist/services/amplitudeService/eventNames.d.ts +2 -1
  64. package/dist/services/amplitudeService/eventNames.js +2 -1
  65. package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
  66. package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
  67. package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
  68. package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
  69. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
  70. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
  71. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
  72. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
  73. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
  74. package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
  75. package/dist/services/hardcopyService/hardcopyService.cjs +31 -6
  76. package/dist/services/hardcopyService/hardcopyService.d.cts +5 -2
  77. package/dist/services/hardcopyService/hardcopyService.d.ts +5 -2
  78. package/dist/services/hardcopyService/hardcopyService.js +31 -7
  79. package/dist/services/hardcopyService/index.cjs +1 -0
  80. package/dist/services/hardcopyService/index.d.cts +2 -2
  81. package/dist/services/hardcopyService/index.d.ts +2 -2
  82. package/dist/services/hardcopyService/index.js +2 -2
  83. package/dist/services/urlResolverWarmupService/index.cjs +3 -3
  84. package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
  85. package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
  86. package/dist/services/urlResolverWarmupService/index.js +3 -3
  87. package/package.json +5 -1
  88. package/src/application/models/featureGates.ts +6 -0
  89. package/src/hooks/Intersection/useIntersection.ts +11 -0
  90. package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
  91. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
  92. package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
  93. package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
  94. package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
  95. package/src/services/amplitudeService/eventNames.ts +4 -0
  96. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
  97. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
  98. package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
  99. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
  100. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
  101. package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
  102. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +73 -3
  103. package/src/services/hardcopyService/hardcopyService.ts +55 -5
  104. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
  105. 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,5 @@
1
+ export { useWidgetLoadDiagnostics, WIDGET_LOAD_STALL_MS } from './useWidgetLoadDiagnostics';
2
+ export type {
3
+ WidgetLoadDiagnosticsInput,
4
+ WidgetVisibilitySource,
5
+ } from './useWidgetLoadDiagnostics';
@@ -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
+ });