@envive-ai/react-hooks 0.3.63 → 0.3.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/application/models/featureGates.cjs +2 -2
  2. package/dist/application/models/featureGates.d.cts +2 -2
  3. package/dist/application/models/featureGates.d.ts +2 -2
  4. package/dist/application/models/featureGates.js +2 -2
  5. package/dist/application/utils/elementObserver.d.cts +2 -2
  6. package/dist/atoms/app/index.d.cts +7 -7
  7. package/dist/atoms/app/index.d.ts +1 -1
  8. package/dist/atoms/app/variant.d.ts +6 -6
  9. package/dist/atoms/chat/chatState.d.cts +19 -19
  10. package/dist/atoms/chat/chatState.d.ts +19 -19
  11. package/dist/atoms/chat/form.d.cts +2 -2
  12. package/dist/atoms/chat/form.d.ts +3 -3
  13. package/dist/atoms/chat/index.d.ts +3 -3
  14. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  15. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  16. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  17. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  18. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  19. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  20. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  21. package/dist/atoms/chat/replies.d.cts +3 -3
  22. package/dist/atoms/chat/suggestions.d.ts +3 -3
  23. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  24. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  25. package/dist/atoms/org/customerService.d.cts +6 -6
  26. package/dist/atoms/org/customerService.d.ts +6 -6
  27. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  28. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  29. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  30. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  31. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  32. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  33. package/dist/atoms/search/types.d.cts +1 -1
  34. package/dist/atoms/search/utils.d.cts +1 -1
  35. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  36. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  37. package/dist/contexts/pageContext/pageContext.cjs +12 -24
  38. package/dist/contexts/pageContext/pageContext.js +12 -24
  39. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  40. package/dist/contexts/types.d.cts +1 -1
  41. package/dist/contexts/types.d.ts +1 -1
  42. package/dist/contexts/typesV3.cjs +8 -1
  43. package/dist/contexts/typesV3.d.cts +23 -6
  44. package/dist/contexts/typesV3.d.ts +23 -6
  45. package/dist/contexts/typesV3.js +8 -2
  46. package/dist/hooks/Intersection/useIntersection.cjs +9 -2
  47. package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
  48. package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
  49. package/dist/hooks/Intersection/useIntersection.js +9 -2
  50. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  51. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
  52. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
  53. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
  54. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
  55. package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
  56. package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
  57. package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
  58. package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
  59. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
  60. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
  61. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
  62. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
  63. package/dist/hooks/utils.d.cts +1 -1
  64. package/dist/hooks/utils.d.ts +1 -1
  65. package/dist/services/amplitudeService/eventNames.cjs +2 -1
  66. package/dist/services/amplitudeService/eventNames.d.cts +2 -1
  67. package/dist/services/amplitudeService/eventNames.d.ts +2 -1
  68. package/dist/services/amplitudeService/eventNames.js +2 -1
  69. package/dist/services/enviveConfigService/enviveConfigService.cjs +21 -35
  70. package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -2
  71. package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -2
  72. package/dist/services/enviveConfigService/enviveConfigService.js +21 -35
  73. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +15 -16
  74. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +15 -16
  75. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +1 -69
  76. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -69
  77. package/dist/services/featureFlagService/index.cjs +7 -5
  78. package/dist/services/featureFlagService/index.d.cts +2 -1
  79. package/dist/services/featureFlagService/index.d.ts +2 -1
  80. package/dist/services/featureFlagService/index.js +7 -5
  81. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
  82. package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
  83. package/dist/services/hardcopyService/hardcopyService.cjs +12 -3
  84. package/dist/services/hardcopyService/hardcopyService.d.cts +3 -1
  85. package/dist/services/hardcopyService/hardcopyService.d.ts +3 -1
  86. package/dist/services/hardcopyService/hardcopyService.js +12 -3
  87. package/package.json +5 -1
  88. package/src/application/models/featureGates.ts +11 -8
  89. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +4 -59
  90. package/src/contexts/pageContext/pageContext.tsx +20 -41
  91. package/src/contexts/typesV3.ts +22 -4
  92. package/src/hooks/Intersection/useIntersection.ts +11 -0
  93. package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
  94. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
  95. package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
  96. package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
  97. package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
  98. package/src/services/amplitudeService/eventNames.ts +4 -0
  99. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +72 -158
  100. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +111 -13
  101. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +161 -490
  102. package/src/services/enviveConfigService/enviveConfigService.ts +51 -87
  103. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +74 -66
  104. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +0 -120
  105. package/src/services/featureFlagService/__tests__/getFeatureFlagOverrides.test.ts +54 -0
  106. package/src/services/featureFlagService/index.ts +19 -6
  107. package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
  108. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +16 -5
  109. package/src/services/hardcopyService/hardcopyService.ts +10 -2
  110. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +0 -42
  111. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +0 -41
  112. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +0 -55
@@ -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
  }
@@ -3,65 +3,15 @@ import { EnviveConfigService } from '../enviveConfigService';
3
3
  const mockFetch = vi.fn();
4
4
  globalThis.fetch = mockFetch;
5
5
 
6
- // getWidgetConfig is the single config call now: it returns org identity +
7
- // gates + experiment assignments alongside productsConfig (the separate
8
- // GET /v1/org/config fetch was removed). productsConfig has no colors, so
9
- // fetchUnifiedGraphQLConfig falls back to its mock config — fine here since
10
- // these tests only care about caching / fetch-count behaviour.
11
- function makeOkConfigFetch() {
12
- mockFetch.mockResolvedValueOnce({
13
- ok: true,
14
- json: () =>
15
- Promise.resolve({
16
- data: {
17
- me: {
18
- org: {
19
- id: 'org-1',
20
- shortName: 'test-org',
21
- displayName: 'Test Org',
22
- domain: 'test.com',
23
- createdAt: '2024-01-01T00:00:00.000Z',
24
- updatedAt: '2024-01-01T00:00:00.000Z',
25
- },
26
- getWidgetConfig: { productsConfig: {}, gates: [], experimentAssignments: [] },
27
- },
28
- },
29
- }),
30
- });
31
- }
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() {
6
+ // /v1/session/bootstrap is the single session-config call now (getWidgetConfig
7
+ // and the separate GET /v1/org/config were removed). A valid response carries
8
+ // org identity + a NON-EMPTY gates array (the malformed-payload guard requires
9
+ // it) + widget_config. orgId is parameterised so cache tests can assert the
10
+ // config actually came back; urlResolving is null-able for the config-only path.
11
+ function makeBootstrapFetch(
12
+ orgId = 'org-1',
13
+ urlResolving: unknown = { variant_type: 'pdp', specific_details: {}, ready: true },
14
+ ) {
65
15
  mockFetch.mockResolvedValueOnce({
66
16
  ok: true,
67
17
  json: () =>
@@ -69,10 +19,10 @@ function makeBootstrapFetch() {
69
19
  org_config: {
70
20
  org: {
71
21
  org: {
72
- id: 'org-boot',
73
- short_name: 'boot-org',
74
- display_name: 'Boot Org',
75
- domain: 'boot.com',
22
+ id: orgId,
23
+ short_name: 'test-org',
24
+ display_name: 'Test Org',
25
+ domain: 'test.com',
76
26
  status: 'active',
77
27
  created_at: '2024-01-01T00:00:00.000Z',
78
28
  updated_at: '2024-01-01T00:00:00.000Z',
@@ -84,7 +34,7 @@ function makeBootstrapFetch() {
84
34
  },
85
35
  widget_config: {
86
36
  products_config: {},
87
- // Assignments come from widget_config (matches getWidgetConfig, ungated).
37
+ // Assignments come from widget_config (ungated).
88
38
  experiment_assignments: [
89
39
  {
90
40
  layer_name: 'ml',
@@ -95,7 +45,7 @@ function makeBootstrapFetch() {
95
45
  ],
96
46
  resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
97
47
  },
98
- url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
48
+ url_resolving: urlResolving,
99
49
  }),
100
50
  });
101
51
  }
@@ -125,17 +75,18 @@ afterEach(() => {
125
75
  });
126
76
 
127
77
  describe('EnviveConfigService caching', () => {
128
- it('fetches the unified config once on a cold cache', async () => {
129
- makeOkConfigFetch();
78
+ it('fetches the session config once on a cold cache', async () => {
79
+ makeBootstrapFetch();
130
80
 
131
81
  const result = await makeService().getEnviveConfig();
132
82
 
133
83
  expect(mockFetch).toHaveBeenCalledTimes(1);
84
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
134
85
  expect(result.org.org.id).toBe('org-1');
135
86
  });
136
87
 
137
88
  it('serves a warm cache to a new instance with identical params without hitting the network', async () => {
138
- makeOkConfigFetch();
89
+ makeBootstrapFetch();
139
90
  await makeService().getEnviveConfig();
140
91
  expect(mockFetch).toHaveBeenCalledTimes(1);
141
92
 
@@ -151,13 +102,13 @@ describe('EnviveConfigService caching', () => {
151
102
  vi.useFakeTimers();
152
103
  vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
153
104
 
154
- makeOkConfigFetch();
105
+ makeBootstrapFetch();
155
106
  await makeService().getEnviveConfig();
156
107
  expect(mockFetch).toHaveBeenCalledTimes(1);
157
108
 
158
109
  vi.setSystemTime(new Date('2026-01-01T00:05:01.000Z'));
159
110
 
160
- makeOkConfigFetch();
111
+ makeBootstrapFetch();
161
112
  await makeService().getEnviveConfig();
162
113
 
163
114
  expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -168,11 +119,11 @@ describe('EnviveConfigService caching', () => {
168
119
  async param => {
169
120
  window.history.replaceState({}, '', `/?${param}=1.2.3`);
170
121
 
171
- makeOkConfigFetch();
122
+ makeBootstrapFetch();
172
123
  await makeService().getEnviveConfig();
173
124
  expect(mockFetch).toHaveBeenCalledTimes(1);
174
125
 
175
- makeOkConfigFetch();
126
+ makeBootstrapFetch();
176
127
  await makeService().getEnviveConfig();
177
128
 
178
129
  // No cache read/write happened, so a second instance refetches instead
@@ -183,33 +134,33 @@ describe('EnviveConfigService caching', () => {
183
134
 
184
135
  it('does not read a version-pinned cache entry after the override is removed', async () => {
185
136
  window.history.replaceState({}, '', '/?envive_config_version=1.2.3');
186
- makeOkConfigFetch();
137
+ makeBootstrapFetch();
187
138
  await makeService().getEnviveConfig();
188
139
 
189
140
  window.history.replaceState({}, '', '/');
190
- makeOkConfigFetch();
141
+ makeBootstrapFetch();
191
142
  await makeService().getEnviveConfig();
192
143
 
193
144
  expect(mockFetch).toHaveBeenCalledTimes(2);
194
145
  });
195
146
 
196
147
  it('keeps separate cache entries per userId', async () => {
197
- makeOkConfigFetch();
148
+ makeBootstrapFetch();
198
149
  await makeService({ userId: 'user-1' }).getEnviveConfig();
199
150
  expect(mockFetch).toHaveBeenCalledTimes(1);
200
151
 
201
- makeOkConfigFetch();
152
+ makeBootstrapFetch();
202
153
  await makeService({ userId: 'user-2' }).getEnviveConfig();
203
154
 
204
155
  expect(mockFetch).toHaveBeenCalledTimes(2);
205
156
  });
206
157
 
207
158
  it('keeps separate cache entries per namespace/source/baseUrl/apiKey', async () => {
208
- makeOkConfigFetch();
159
+ makeBootstrapFetch();
209
160
  await makeService({ namespace: 'ns-a' }).getEnviveConfig();
210
161
  expect(mockFetch).toHaveBeenCalledTimes(1);
211
162
 
212
- makeOkConfigFetch();
163
+ makeBootstrapFetch();
213
164
  await makeService({ namespace: 'ns-b' }).getEnviveConfig();
214
165
 
215
166
  expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -220,7 +171,7 @@ describe('EnviveConfigService caching', () => {
220
171
  throw new Error('quota exceeded');
221
172
  });
222
173
 
223
- makeOkConfigFetch();
174
+ makeBootstrapFetch();
224
175
 
225
176
  const result = await makeService().getEnviveConfig();
226
177
 
@@ -232,7 +183,7 @@ describe('EnviveConfigService caching', () => {
232
183
  throw new Error('storage disabled');
233
184
  });
234
185
 
235
- makeOkConfigFetch();
186
+ makeBootstrapFetch();
236
187
 
237
188
  const result = await makeService().getEnviveConfig();
238
189
 
@@ -241,47 +192,34 @@ describe('EnviveConfigService caching', () => {
241
192
 
242
193
  it('fails open and refetches when a cached entry is corrupted JSON', async () => {
243
194
  const service = makeService();
244
- makeOkConfigFetch();
195
+ makeBootstrapFetch();
245
196
  await service.getEnviveConfig();
246
197
  const [key] = Object.keys(sessionStorage);
247
198
  sessionStorage.setItem(key, 'not json');
248
199
 
249
- makeOkConfigFetch();
200
+ makeBootstrapFetch();
250
201
  const result = await makeService().getEnviveConfig();
251
202
 
252
203
  expect(result.org.org.id).toBe('org-1');
253
204
  });
254
205
  });
255
206
 
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).
207
+ describe('EnviveConfigService bootstrap request', () => {
208
+ // The injection bundle supplies env + contextSource, which request bootstrap's
209
+ // url_resolving branch. Without them the branch is omitted (see the config-only
210
+ // case below), but bootstrap is still the config call either way.
259
211
  const bootstrapService = () => makeService({ env: 'dev', contextSource: 'app' });
260
212
 
261
- it('stays on getWidgetConfig (no bootstrap) when the gate is off', async () => {
262
- makeOkConfigFetch(); // gates: []
213
+ it('issues a single /v1/session/bootstrap call and returns its config', async () => {
214
+ makeBootstrapFetch('org-boot');
263
215
 
264
216
  const service = bootstrapService();
265
217
  const result = await service.getEnviveConfig();
266
218
 
267
219
  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.
220
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
283
221
  expect(result.org.org.id).toBe('org-boot');
284
- expect(result.org.org.short_name).toBe('boot-org');
222
+ expect(result.org.org.short_name).toBe('test-org');
285
223
  // gates: snake group_name mapped to camel groupName.
286
224
  expect(result.gates).toContainEqual({
287
225
  name: 'use_unified_config',
@@ -300,7 +238,6 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
300
238
  });
301
239
 
302
240
  it('stashes bootstrap url_resolving (paired with its URL) for the warm-up to consume once', async () => {
303
- makeGateOnConfigFetch();
304
241
  makeBootstrapFetch();
305
242
 
306
243
  const service = bootstrapService();
@@ -315,96 +252,73 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
315
252
  expect(service.takeBootstrapUrlResolving()).toBeNull();
316
253
  });
317
254
 
318
- it('sends url_resolving context + gate names + version pin in the bootstrap body', async () => {
319
- makeGateOnConfigFetch();
255
+ it('sends the url_resolving context (no org identity), gate names, and namespace in the body', async () => {
320
256
  makeBootstrapFetch();
321
257
 
322
258
  await bootstrapService().getEnviveConfig();
323
259
 
324
- const [, options] = mockFetch.mock.calls[1];
260
+ const [, options] = mockFetch.mock.calls[0];
325
261
  const body = JSON.parse(options.body as string);
326
262
  expect(body.include_feature_gates).toContain('use_unified_config');
327
263
  // Scopes org_config.configs so bootstrap doesn't dump internal configs to the browser.
328
264
  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.
265
+ // org identity is NOT sent — the server fills it from the authenticated org.
266
+ expect(body.url_resolving.context).toMatchObject({ source: 'app', env: 'dev' });
267
+ expect(body.url_resolving.context.org_id).toBeUndefined();
268
+ expect(body.url_resolving.context.org_short_name).toBeUndefined();
269
+ // No config-version override set here.
338
270
  expect(body.url_resolving.org_base_config_version).toBeUndefined();
339
271
  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);
272
+ // No client gate overrides set → empty map (server resolves the real gates).
273
+ expect(body.url_resolving.feature_gates).toEqual({});
343
274
  // the persistent chat session id is threaded (not an empty string).
344
275
  expect(body.url_resolving.context.chat_id).toBeTruthy();
345
276
  });
346
277
 
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.
278
+ it('forwards ?<gate>=true overrides in the bootstrap feature_gates it sends', async () => {
279
+ // Client overrides (query param) must reach bootstrap's url_resolving so they
280
+ // still drive page-variant selection; the server merges them over its gates.
350
281
  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
282
  makeBootstrapFetch();
353
283
 
354
284
  await bootstrapService().getEnviveConfig();
355
285
 
356
- const body = JSON.parse(mockFetch.mock.calls[1][1].body as string);
286
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
287
+ expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
357
288
  expect(body.url_resolving.feature_gates.is_empty_div_plp_enabled).toBe(true);
358
289
  });
359
290
 
360
- it('skips bootstrap for non-bundle callers (no env/contextSource) even when the gate is on', async () => {
361
- makeGateOnConfigFetch();
291
+ it('omits the url_resolving branch for non-bundle callers (no env/contextSource) but still fetches config', async () => {
292
+ makeBootstrapFetch('org-1', null);
362
293
 
363
- const result = await makeService().getEnviveConfig(); // no env/contextSource
294
+ const service = makeService(); // no env/contextSource
295
+ const result = await service.getEnviveConfig();
364
296
 
365
297
  expect(mockFetch).toHaveBeenCalledTimes(1);
298
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
299
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
300
+ expect(body.url_resolving).toBeUndefined();
366
301
  expect(result.org.org.id).toBe('org-1');
302
+ expect(service.takeBootstrapUrlResolving()).toBeNull();
367
303
  });
368
304
 
369
- it('falls back to the getWidgetConfig config when bootstrap fails', async () => {
370
- makeGateOnConfigFetch();
305
+ it('throws (aborting injection) when bootstrap fails — there is no getWidgetConfig fallback', async () => {
371
306
  mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
372
307
 
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();
308
+ await expect(bootstrapService().getEnviveConfig()).rejects.toThrow(
309
+ 'Bootstrap config request failed',
310
+ );
380
311
  });
381
312
 
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).
313
+ it('throws when bootstrap returns a malformed 2xx payload', async () => {
314
+ // 2xx but missing required sections — must NOT become a config with empty
315
+ // gates / mock products (which would disable the client session). No fallback
316
+ // left, so the throw propagates and injection aborts.
386
317
  mockFetch.mockResolvedValueOnce({
387
318
  ok: true,
388
319
  json: () => Promise.resolve({ org_config: {}, widget_config: null }),
389
320
  });
390
321
 
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');
322
+ await expect(bootstrapService().getEnviveConfig()).rejects.toThrow('missing required sections');
409
323
  });
410
324
  });