@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,271 @@
1
+ import { type FetchBootstrapConfigParams, fetchBootstrapConfig } from '../fetchBootstrapConfig';
2
+
3
+ const mockFetch = vi.fn();
4
+ globalThis.fetch = mockFetch;
5
+
6
+ // A representative /v1/session/bootstrap response (REST snake_case). products_config
7
+ // is left empty so the shared transformV3ProductsConfig falls back to mock config —
8
+ // its internals are covered by fetchGraphQLConfig.test.ts; here we only assert the
9
+ // bootstrap-specific mapping (org/gates/assignments/resolution/url_resolving).
10
+ function makeBootstrapResponse() {
11
+ mockFetch.mockResolvedValueOnce({
12
+ ok: true,
13
+ json: () =>
14
+ Promise.resolve({
15
+ org_config: {
16
+ org: {
17
+ org: {
18
+ id: 'org-boot',
19
+ short_name: 'boot-org',
20
+ display_name: 'Boot Org',
21
+ domain: 'boot.com',
22
+ status: 'active',
23
+ created_at: '2024-01-01T00:00:00.000Z',
24
+ updated_at: '2024-02-02T00:00:00.000Z',
25
+ },
26
+ settings: {},
27
+ },
28
+ gates: [
29
+ { name: 'use_unified_config', value: true, group_name: 'Treatment', rule_id: 'r1' },
30
+ { name: 'is_client_session_enabled', value: false, group_name: null, rule_id: null },
31
+ ],
32
+ // Decoy: org_config.experiment_assignments is server-gated on
33
+ // unified_config_active and must NOT be the source. If the mapper reads
34
+ // this, the assertion below fails.
35
+ experiment_assignments: [
36
+ {
37
+ layer_name: 'DECOY_org_config',
38
+ namespace: 'ml',
39
+ allocated_experiment_name: 'should_not_be_read',
40
+ group_name: 'X',
41
+ },
42
+ ],
43
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
44
+ },
45
+ widget_config: {
46
+ products_config: {},
47
+ // The correct source — mirrors getWidgetConfig (ungated).
48
+ experiment_assignments: [
49
+ {
50
+ layer_name: 'ml',
51
+ namespace: 'ml',
52
+ allocated_experiment_name: 'exp_a',
53
+ group_name: 'A',
54
+ },
55
+ ],
56
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
57
+ },
58
+ url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
59
+ }),
60
+ });
61
+ }
62
+
63
+ const baseParams: FetchBootstrapConfigParams = {
64
+ baseUrl: 'https://commerce-api.spiffy.ai',
65
+ apiKey: 'test-key',
66
+ userId: 'user-1',
67
+ source: 'app',
68
+ namespace: 'spiffy-react-components',
69
+ gateNames: ['use_unified_config', 'is_client_session_enabled'],
70
+ url: 'https://shop.example.com/products/thing',
71
+ orgId: 'org-1',
72
+ orgShortName: 'test-org',
73
+ contextSource: 'app',
74
+ env: 'prod',
75
+ featureGates: { use_unified_config: true, is_empty_div_plp_enabled: true },
76
+ };
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ window.history.replaceState({}, '', '/');
81
+ });
82
+
83
+ describe('fetchBootstrapConfig', () => {
84
+ it('POSTs to /v1/session/bootstrap with the bearer key', async () => {
85
+ makeBootstrapResponse();
86
+
87
+ await fetchBootstrapConfig(baseParams);
88
+
89
+ const [url, options] = mockFetch.mock.calls[0];
90
+ expect(url).toBe('https://commerce-api.spiffy.ai/v1/session/bootstrap');
91
+ expect(options.method).toBe('POST');
92
+ expect(options.headers.Authorization).toBe('Bearer test-key');
93
+ });
94
+
95
+ it('maps org identity, gates (group_name→groupName), assignments, and resolution', async () => {
96
+ makeBootstrapResponse();
97
+
98
+ const { config } = await fetchBootstrapConfig(baseParams);
99
+
100
+ expect(config.org.org).toEqual({
101
+ id: 'org-boot',
102
+ short_name: 'boot-org',
103
+ display_name: 'Boot Org',
104
+ domain: 'boot.com',
105
+ status: 'active',
106
+ created_at: '2024-01-01T00:00:00.000Z',
107
+ updated_at: '2024-02-02T00:00:00.000Z',
108
+ });
109
+ // snake group_name → camel groupName; null → undefined.
110
+ expect(config.gates).toEqual([
111
+ { name: 'use_unified_config', value: true, groupName: 'Treatment' },
112
+ { name: 'is_client_session_enabled', value: false, groupName: undefined },
113
+ ]);
114
+ // experiment_assignments already snake — pass straight through.
115
+ expect(config.experiment_assignments).toEqual([
116
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
117
+ ]);
118
+ // resolution snake → camel, attached to orgConfig.
119
+ expect(config.orgConfig?.resolution).toEqual({
120
+ baseVersion: '1.0.0',
121
+ baseVersionSource: 'merchant_layer',
122
+ });
123
+ });
124
+
125
+ it('reads experiment_assignments from widget_config, not the server-gated org_config', async () => {
126
+ makeBootstrapResponse();
127
+
128
+ const { config } = await fetchBootstrapConfig(baseParams);
129
+
130
+ // org_config carried a DECOY; the value must come from widget_config so it
131
+ // matches getWidgetConfig (which is ungated by use_unified_config). Reading
132
+ // org_config would drop assignments whenever the gate is off server-side.
133
+ expect(config.experiment_assignments).toEqual([
134
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
135
+ ]);
136
+ expect(config.experiment_assignments?.some(a => a.layer_name === 'DECOY_org_config')).toBe(
137
+ false,
138
+ );
139
+ });
140
+
141
+ it('returns the url_resolving payload verbatim for the warm-up to seed', async () => {
142
+ makeBootstrapResponse();
143
+
144
+ const { urlResolving } = await fetchBootstrapConfig(baseParams);
145
+
146
+ expect(urlResolving).toEqual({ variant_type: 'pdp', specific_details: {}, ready: true });
147
+ });
148
+
149
+ it('returns urlResolving null when bootstrap omitted the branch', async () => {
150
+ mockFetch.mockResolvedValueOnce({
151
+ ok: true,
152
+ json: () =>
153
+ Promise.resolve({
154
+ org_config: {
155
+ org: { org: { id: 'o' } },
156
+ gates: [{ name: 'use_unified_config', value: true }],
157
+ },
158
+ widget_config: { products_config: {} },
159
+ url_resolving: null,
160
+ }),
161
+ });
162
+
163
+ const { urlResolving } = await fetchBootstrapConfig(baseParams);
164
+
165
+ expect(urlResolving).toBeNull();
166
+ });
167
+
168
+ it('builds the url_resolving context and version split from params', async () => {
169
+ makeBootstrapResponse();
170
+
171
+ await fetchBootstrapConfig({ ...baseParams, chatId: 'chat-9' });
172
+
173
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
174
+ expect(body).toMatchObject({
175
+ user_id: 'user-1',
176
+ source: 'app',
177
+ // Scopes org_config.configs — must be sent or bootstrap dumps internal configs.
178
+ namespace: 'spiffy-react-components',
179
+ include_feature_gates: ['use_unified_config', 'is_client_session_enabled'],
180
+ });
181
+ expect(body.url_resolving).toMatchObject({
182
+ url: 'https://shop.example.com/products/thing',
183
+ // Override-aware gates forwarded so client overrides reach page-variant resolution.
184
+ feature_gates: { use_unified_config: true, is_empty_div_plp_enabled: true },
185
+ });
186
+ // No org_base_config_version — the version comes only from the URL query param
187
+ // (override_config_version), never from getWidgetConfig's resolution.
188
+ expect(body.url_resolving.org_base_config_version).toBeUndefined();
189
+ expect(body.url_resolving.context).toEqual({
190
+ user_id: 'user-1',
191
+ org_id: 'org-1',
192
+ org_short_name: 'test-org',
193
+ chat_id: 'chat-9',
194
+ source: 'app',
195
+ env: 'prod',
196
+ });
197
+ });
198
+
199
+ it('threads a config-version query param into version + override_config_version', async () => {
200
+ window.history.replaceState({}, '', '/?envive_config_version=9.9.9');
201
+ makeBootstrapResponse();
202
+
203
+ await fetchBootstrapConfig(baseParams);
204
+
205
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
206
+ expect(body.version).toBe('9.9.9');
207
+ expect(body.url_resolving.override_config_version).toBe('9.9.9');
208
+ });
209
+
210
+ it('throws on a non-ok response so the caller can fall back', async () => {
211
+ mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
212
+
213
+ await expect(fetchBootstrapConfig(baseParams)).rejects.toThrow(
214
+ 'Bootstrap config request failed',
215
+ );
216
+ });
217
+
218
+ // Each case isolates ONE missing/degraded required section (the others valid).
219
+ const okGates = [{ name: 'use_unified_config', value: true }];
220
+ const okOrg = { org: { org: { id: 'o' } }, gates: okGates };
221
+ const okWidget = { products_config: {} };
222
+ it.each([
223
+ ['missing org_config', { org_config: undefined, widget_config: okWidget }],
224
+ [
225
+ 'partial org_config (no org.org)',
226
+ { org_config: { org: {}, gates: okGates }, widget_config: okWidget },
227
+ ],
228
+ ['absent gates', { org_config: { org: { org: { id: 'o' } } }, widget_config: okWidget }],
229
+ [
230
+ 'empty gates',
231
+ { org_config: { org: { org: { id: 'o' } }, gates: [] }, widget_config: okWidget },
232
+ ],
233
+ ['missing widget_config', { org_config: okOrg, widget_config: null }],
234
+ ])(
235
+ 'throws on a 2xx with %s so the caller can fall back to getWidgetConfig',
236
+ async (_label, payload) => {
237
+ mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(payload) });
238
+
239
+ await expect(fetchBootstrapConfig(baseParams)).rejects.toThrow('missing required sections');
240
+ },
241
+ );
242
+
243
+ it('passes an AbortSignal to fetch (timeout wiring)', async () => {
244
+ makeBootstrapResponse();
245
+
246
+ await fetchBootstrapConfig(baseParams);
247
+
248
+ const [, options] = mockFetch.mock.calls[0];
249
+ expect(options.signal).toBeInstanceOf(AbortSignal);
250
+ });
251
+
252
+ it('aborts and rejects when the request exceeds the timeout so the caller can fall back', async () => {
253
+ vi.useFakeTimers();
254
+ // Mimic real fetch: reject with an AbortError once its signal aborts.
255
+ mockFetch.mockImplementationOnce(
256
+ (_url: string, opts: { signal: AbortSignal }) =>
257
+ new Promise((_resolve, reject) => {
258
+ opts.signal.addEventListener('abort', () =>
259
+ reject(new DOMException('The operation was aborted', 'AbortError')),
260
+ );
261
+ }),
262
+ );
263
+
264
+ const promise = fetchBootstrapConfig(baseParams);
265
+ const rejection = expect(promise).rejects.toThrow();
266
+ await vi.advanceTimersByTimeAsync(5000);
267
+ await rejection;
268
+
269
+ vi.useRealTimers();
270
+ });
271
+ });
@@ -2,7 +2,15 @@ import { OrgConfigExperimentAssignment, OrgConfigFeatureGate } from 'src/applica
2
2
  import { FeatureGates } from 'src/application/models/featureGates';
3
3
  import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
4
4
  import { configVersionOverride } from 'src/types/config-versions';
5
+ import { FeatureFlagService } from 'src/services/featureFlagService';
6
+ import Logger from 'src/application/logging/logger';
7
+ import type { UrlResolverResponse } from 'src/atoms/app/variant';
8
+ import { getAtomStore } from 'src/atoms/atomStore/atomStore';
9
+ import { chatIdAtom } from 'src/atoms/app';
5
10
  import { fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
11
+ import { fetchBootstrapConfig } from './fetchBootstrapConfig';
12
+
13
+ const logger = new Logger('enviveConfigService');
6
14
 
7
15
  // Short-lived so config changes (gates, experiments) still propagate within
8
16
  // a session, while sparing every page navigation from re-fetching org
@@ -21,6 +29,12 @@ type EnviveConfigServiceProps = {
21
29
  userId: string;
22
30
  namespace: string;
23
31
  source: string;
32
+ // The url_resolving context bits the hooks layer can't derive itself, supplied
33
+ // by the injection bundle so getEnviveConfig can call /v1/session/bootstrap on
34
+ // the use_unified_config gate-ON path. Optional: when absent (mocks, non-bundle
35
+ // callers) the bootstrap branch is skipped and getWidgetConfig stands.
36
+ env?: string;
37
+ contextSource?: string;
24
38
  };
25
39
 
26
40
  export type EnviveServiceConfig = {
@@ -36,9 +50,10 @@ export type EnviveServiceConfig = {
36
50
  };
37
51
  };
38
52
  gates: OrgConfigFeatureGate[];
39
- // Populated by the backend when the `use_unified_config` Statsig gate
40
- // evaluates true. amplitudeService reads these to enrich events with
41
- // `experiment.{ns}.*` properties.
53
+ // The user's Statsig experiment-layer assignments, from the widget config's
54
+ // resolved experiments getWidgetConfig, or bootstrap's `widget_config`
55
+ // (both ungated by use_unified_config; empty only under a version pin).
56
+ // amplitudeService reads these to enrich events with `experiment.{ns}.*` props.
42
57
  experiment_assignments?: OrgConfigExperimentAssignment[];
43
58
  // orgConfig.resolution is populated only on the unified path (sourced from
44
59
  // getWidgetConfig.resolution, not /v1/org/config.resolution) so the version
@@ -51,8 +66,18 @@ export interface IEnviveConfigService {
51
66
  readonly baseUrl: string;
52
67
  readonly apiKey: string;
53
68
  getEnviveConfig(): Promise<EnviveServiceConfig>;
69
+ // NB: `takeBootstrapUrlResolving` is intentionally NOT on this interface. It's
70
+ // a bootstrap-only detail consumed solely by the injection bundle via the
71
+ // concrete EnviveConfigService; keeping it off the public DI contract avoids a
72
+ // breaking change for external consumers that supply their own config service
73
+ // (EnviveProvider only ever calls getEnviveConfig / baseUrl / apiKey).
54
74
  }
55
75
 
76
+ // The bootstrap url_resolving payload plus the URL it was resolved for, so the
77
+ // warm-up can verify the URL still matches before seeding (see F1: a SPA nav
78
+ // during the bootstrap round trip must not cache URL A's result under URL B).
79
+ type BootstrapUrlResolving = { url: string; response: UrlResolverResponse };
80
+
56
81
  export class EnviveConfigService implements IEnviveConfigService {
57
82
  public readonly baseUrl: string;
58
83
 
@@ -64,14 +89,40 @@ export class EnviveConfigService implements IEnviveConfigService {
64
89
 
65
90
  private readonly source: string;
66
91
 
92
+ private readonly env?: string;
93
+
94
+ private readonly contextSource?: string;
95
+
67
96
  private response: EnviveServiceConfig | null = null;
68
97
 
69
- constructor({ baseUrl, apiKey, userId, namespace, source }: EnviveConfigServiceProps) {
98
+ private bootstrapUrlResolving: BootstrapUrlResolving | null = null;
99
+
100
+ constructor({
101
+ baseUrl,
102
+ apiKey,
103
+ userId,
104
+ namespace,
105
+ source,
106
+ env,
107
+ contextSource,
108
+ }: EnviveConfigServiceProps) {
70
109
  this.baseUrl = baseUrl;
71
110
  this.apiKey = apiKey;
72
111
  this.userId = userId;
73
112
  this.namespace = namespace;
74
113
  this.source = source;
114
+ this.env = env;
115
+ this.contextSource = contextSource;
116
+ }
117
+
118
+ // Hands off (once) the url_resolving payload that /v1/session/bootstrap
119
+ // returned alongside the config — paired with the URL it was resolved for — so
120
+ // the warm-up seeds urlResolverAtom with it instead of firing a separate
121
+ // /v1/url_resolving. null on the getWidgetConfig / cached path.
122
+ takeBootstrapUrlResolving(): BootstrapUrlResolving | null {
123
+ const value = this.bootstrapUrlResolving;
124
+ this.bootstrapUrlResolving = null;
125
+ return value;
75
126
  }
76
127
 
77
128
  private getCacheKey(): string {
@@ -132,16 +183,84 @@ export class EnviveConfigService implements IEnviveConfigService {
132
183
  // list of gates the app checks, so the resolver evaluates and returns them.
133
184
  const gateNames = Object.values(FeatureGates).map(featureGate => featureGate.toString());
134
185
 
135
- this.response = await fetchUnifiedGraphQLConfig(
186
+ const widgetConfig = await fetchUnifiedGraphQLConfig(
136
187
  this.baseUrl,
137
188
  this.apiKey,
138
189
  this.userId,
139
190
  gateNames,
140
191
  this.source,
141
192
  );
193
+
194
+ this.response = await this.maybeUpgradeToBootstrap(widgetConfig, gateNames);
142
195
  this.writeCachedConfig(this.response);
143
196
  return this.response;
144
197
  }
198
+
199
+ // The unified-config ramp (`use_unified_config` Statsig gate). When the gate
200
+ // is ON for this user, swap the getWidgetConfig result for Matt's
201
+ // /v1/session/bootstrap — the same config PLUS the initial url_resolving in
202
+ // ONE round trip (the consolidation this migration is about). The gate is read
203
+ // off the getWidgetConfig response just fetched, because the FE has no Statsig
204
+ // SDK: pymono evaluates the gate server-side and returns it. `FeatureFlagService`
205
+ // also honors a `?use_unified_config=true` override, so the bootstrap path is
206
+ // testable before the gate ramps.
207
+ //
208
+ // Only runs on the cache-miss path (bootstrap's config is then cached like any
209
+ // other), so a cached load never re-fetches. Needs env + contextSource for the
210
+ // url_resolving context — absent for mocks / non-bundle callers, where the
211
+ // branch is skipped. A bootstrap failure falls back to the getWidgetConfig
212
+ // config already in hand: a bootstrap problem degrades to the proven path
213
+ // instead of aborting injection. At 100% the getWidgetConfig call above is
214
+ // dropped and bootstrap becomes the sole config call (step 8).
215
+ private async maybeUpgradeToBootstrap(
216
+ widgetConfig: EnviveServiceConfig,
217
+ gateNames: string[],
218
+ ): Promise<EnviveServiceConfig> {
219
+ if (this.env === undefined || this.contextSource === undefined) {
220
+ return widgetConfig;
221
+ }
222
+ // Built once: reads the gate (override-aware) AND supplies the override-aware
223
+ // gate map bootstrap's url_resolving needs (matches the legacy resolveUrl path).
224
+ const featureFlagService = new FeatureFlagService(widgetConfig.gates);
225
+ if (!featureFlagService.isFeatureGateEnabled(FeatureGates.UseUnifiedConfig)) {
226
+ return widgetConfig;
227
+ }
228
+
229
+ // Captured once and reused for both the bootstrap request and the stash, so
230
+ // the URL carried to the warm-up is provably the one bootstrap resolved. The
231
+ // warm-up only seeds it if this still matches the current URL (a SPA nav
232
+ // during the round trip would otherwise cache this result under a later URL).
233
+ // Same cleansing as warmUrlResolver so the urlResolverAtom key lines up.
234
+ const cleansedUrl = window.location.href.toLowerCase().trim();
235
+ try {
236
+ const { config, urlResolving } = await fetchBootstrapConfig({
237
+ baseUrl: this.baseUrl,
238
+ apiKey: this.apiKey,
239
+ userId: this.userId,
240
+ source: this.source,
241
+ // Scope org_config.configs to this namespace so bootstrap doesn't return
242
+ // internal-only org configs to the browser (matches the legacy /v1/org/config).
243
+ namespace: this.namespace,
244
+ gateNames,
245
+ url: cleansedUrl,
246
+ orgId: widgetConfig.org.org.id,
247
+ orgShortName: widgetConfig.org.org.short_name,
248
+ contextSource: this.contextSource,
249
+ env: this.env,
250
+ featureGates: featureFlagService.getFeatureFlags(),
251
+ // The persistent chat session id (same source resolveUrl reads) so
252
+ // bootstrap's url_resolving carries it instead of an empty string.
253
+ chatId: getAtomStore().get(chatIdAtom),
254
+ });
255
+ this.bootstrapUrlResolving = urlResolving
256
+ ? { url: cleansedUrl, response: urlResolving }
257
+ : null;
258
+ return config;
259
+ } catch (err) {
260
+ logger.logError('getEnviveConfig | bootstrap upgrade failed, using getWidgetConfig', err);
261
+ return widgetConfig;
262
+ }
263
+ }
145
264
  }
146
265
 
147
266
  /**