@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
@@ -3,15 +3,11 @@ 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
5
  import { FeatureFlagService } from 'src/services/featureFlagService';
6
- import Logger from 'src/application/logging/logger';
7
6
  import type { UrlResolverResponse } from 'src/atoms/app/variant';
8
7
  import { getAtomStore } from 'src/atoms/atomStore/atomStore';
9
8
  import { chatIdAtom } from 'src/atoms/app';
10
- import { fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
11
9
  import { fetchBootstrapConfig } from './fetchBootstrapConfig';
12
10
 
13
- const logger = new Logger('enviveConfigService');
14
-
15
11
  // Short-lived so config changes (gates, experiments) still propagate within
16
12
  // a session, while sparing every page navigation from re-fetching org
17
13
  // config + GraphQL config, both of which sit on the critical path before
@@ -30,9 +26,9 @@ type EnviveConfigServiceProps = {
30
26
  namespace: string;
31
27
  source: string;
32
28
  // 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.
29
+ // by the injection bundle so getEnviveConfig can request bootstrap's
30
+ // url_resolving branch. Optional: when absent (mocks, or a disabled session)
31
+ // bootstrap is still called for config, just without the url_resolving branch.
36
32
  env?: string;
37
33
  contextSource?: string;
38
34
  };
@@ -118,7 +114,8 @@ export class EnviveConfigService implements IEnviveConfigService {
118
114
  // Hands off (once) the url_resolving payload that /v1/session/bootstrap
119
115
  // returned alongside the config — paired with the URL it was resolved for — so
120
116
  // the warm-up seeds urlResolverAtom with it instead of firing a separate
121
- // /v1/url_resolving. null on the getWidgetConfig / cached path.
117
+ // /v1/url_resolving. null on the cached path or a config-only bootstrap (a
118
+ // disabled session, which requests no url_resolving branch).
122
119
  takeBootstrapUrlResolving(): BootstrapUrlResolving | null {
123
120
  const value = this.bootstrapUrlResolving;
124
121
  this.bootstrapUrlResolving = null;
@@ -176,91 +173,58 @@ export class EnviveConfigService implements IEnviveConfigService {
176
173
  return this.response;
177
174
  }
178
175
 
179
- // getWidgetConfig is the single source of session config: it returns org
180
- // identity + feature gates + experiment assignments (which used to be a
181
- // separate GET /v1/org/config call) alongside productsConfig + the
182
- // resolution metadata threaded into downstream inference. `gateNames` is the
183
- // list of gates the app checks, so the resolver evaluates and returns them.
176
+ // /v1/session/bootstrap is the single session-config call: org identity +
177
+ // feature gates + experiment assignments + productsConfig/resolution, PLUS
178
+ // the initial url_resolving — the consolidation of GET /v1/org/config,
179
+ // GraphQL me.getWidgetConfig, and POST /v1/url_resolving into one round trip.
180
+ // `gateNames` is the full list of gates the app checks, so the server
181
+ // evaluates and returns them. A failure throws (see fetchBootstrapConfig) and
182
+ // propagates to inject.ts, which aborts injection — there is no getWidgetConfig
183
+ // left to fall back to, the same blast radius the single /v1/org/config had.
184
184
  const gateNames = Object.values(FeatureGates).map(featureGate => featureGate.toString());
185
185
 
186
- const widgetConfig = await fetchUnifiedGraphQLConfig(
187
- this.baseUrl,
188
- this.apiKey,
189
- this.userId,
190
- gateNames,
191
- this.source,
192
- );
186
+ // env + contextSource are supplied only by the injection bundle (the hooks
187
+ // layer can't derive them). Present → request bootstrap's url_resolving branch
188
+ // and stash it for the warm-up to seed. Absent (mocks, or a disabled session
189
+ // that still needs org config + gates for the "Envive Initialized" event) →
190
+ // bootstrap returns config only, no page-variant round trip.
191
+ const wantsUrlResolving = this.env !== undefined && this.contextSource !== undefined;
192
+ // Captured once and reused for the stash so the URL carried to the warm-up is
193
+ // provably the one bootstrap resolved. The warm-up only seeds it if this still
194
+ // matches the current URL (a SPA nav during the round trip would otherwise
195
+ // cache this result under a later URL). Same cleansing as warmUrlResolver so
196
+ // the urlResolverAtom key lines up.
197
+ const cleansedUrl = window.location.href.toLowerCase().trim();
193
198
 
194
- this.response = await this.maybeUpgradeToBootstrap(widgetConfig, gateNames);
199
+ const { config, urlResolving } = await fetchBootstrapConfig({
200
+ baseUrl: this.baseUrl,
201
+ apiKey: this.apiKey,
202
+ userId: this.userId,
203
+ source: this.source,
204
+ // Scope org_config.configs to this namespace so bootstrap doesn't return
205
+ // internal-only org configs to the browser (matches the legacy /v1/org/config).
206
+ namespace: this.namespace,
207
+ gateNames,
208
+ // The persistent chat session id (same source resolveUrl reads) so
209
+ // bootstrap's url_resolving carries it instead of an empty string.
210
+ chatId: getAtomStore().get(chatIdAtom),
211
+ ...(wantsUrlResolving
212
+ ? {
213
+ url: cleansedUrl,
214
+ contextSource: this.contextSource,
215
+ env: this.env,
216
+ // Client-side gate overrides only (query/window/localStorage); the
217
+ // server resolves the real gate values and merges these over them.
218
+ featureGates: FeatureFlagService.getFeatureFlagOverrides(),
219
+ }
220
+ : {}),
221
+ });
222
+
223
+ this.bootstrapUrlResolving = urlResolving ? { url: cleansedUrl, response: urlResolving } : null;
224
+ this.response = config;
195
225
  this.writeCachedConfig(this.response);
196
226
  return this.response;
197
227
  }
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
- }
264
228
  }
265
229
 
266
230
  /**
@@ -14,11 +14,6 @@ import type { EnviveServiceConfig } from './enviveConfigService';
14
14
 
15
15
  const logger = new Logger('fetchBootstrapConfig');
16
16
 
17
- // The url-resolving context bits the hooks layer can't derive on its own — the
18
- // injection bundle supplies env (a build-time var) and contextSource. org
19
- // identity is threaded from the getWidgetConfig response fetched moments earlier
20
- // (see enviveConfigService: bootstrap only runs on the gate-ON path, which reads
21
- // the gate off getWidgetConfig first, so org id/short_name are already in hand).
22
17
  export interface FetchBootstrapConfigParams {
23
18
  baseUrl: string;
24
19
  apiKey: string;
@@ -31,22 +26,27 @@ export interface FetchBootstrapConfigParams {
31
26
  // bundle receives. The legacy /v1/org/config call scoped it the same way.
32
27
  namespace: string;
33
28
  gateNames: string[];
34
- // url_resolving inputs:
35
- url: string;
36
- orgId: string;
37
- orgShortName: string;
29
+ chatId?: string;
30
+ // url_resolving inputs, all supplied together by the injection bundle when
31
+ // Envive is enabled. When `url` is present, bootstrap runs its page-variant
32
+ // branch and returns the initial url_resolving; when absent (envive_on=false —
33
+ // there is no page to resolve) the branch is omitted and bootstrap returns
34
+ // config only. org identity is NO LONGER sent: /v1/session/bootstrap fills
35
+ // org_id/org_short_name from the authenticated org (the API key), so the
36
+ // bootstrap-only client — which no longer calls getWidgetConfig — needs no way
37
+ // to learn its own org before the request.
38
+ url?: string;
38
39
  // Context `source` for url_resolving ('app' | 'playground'); may differ from
39
40
  // the config `source` above (playground = Envive Hub).
40
- contextSource: string;
41
- env: string;
42
- chatId?: string;
43
- // The override-aware feature gates (FeatureFlagService.getFeatureFlags()), so
44
- // client-side gate overrides (query/window/localStorage) reach page-variant
45
- // resolution — same as the legacy /v1/url_resolving request. Bootstrap merges
46
- // these OVER its own server-resolved gates. Harmlessly ignored by pymono until
47
- // SessionBootstrapUrlResolvingRequest gains the `feature_gates` field (the
48
- // schema allows unknown fields; the model is `extra='ignore'`), so the FE can
49
- // ship ahead of the backend.
41
+ contextSource?: string;
42
+ env?: string;
43
+ // Override-ONLY feature gates (FeatureFlagService.getFeatureFlagOverrides()):
44
+ // just the client-side overrides (query/window/localStorage), which bootstrap
45
+ // merges OVER its own server-resolved gates for page-variant selection — the
46
+ // legacy /v1/url_resolving behaviour. NOT a full resolved map: on the
47
+ // bootstrap-only path the client has no server gate values to resolve from, so
48
+ // a full map would clobber the server's gates. Empty (the common case, no
49
+ // overrides) leaves the server gates untouched.
50
50
  featureGates?: Record<string, boolean>;
51
51
  }
52
52
 
@@ -127,18 +127,17 @@ const toResolution = (
127
127
  // `me.getWidgetConfig`, and `POST /v1/url_resolving` behind Matt's endpoint.
128
128
  //
129
129
  // Raw fetch (the npm client `@spiffy-ai/commerce-api-client` has no bootstrap
130
- // method, same as getWidgetConfig). Throws on network/HTTP failure/timeout — the
131
- // caller (enviveConfigService) falls back to the getWidgetConfig config it already
132
- // holds, so a bootstrap problem degrades to the proven path rather than aborting
133
- // injection.
130
+ // method). Throws on network/HTTP failure/timeout, which propagates through
131
+ // getEnviveConfig to inject.ts and aborts injection — bootstrap is now the SOLE
132
+ // config call, so there is no getWidgetConfig left to fall back to (the same
133
+ // blast radius the single GET /v1/org/config call always had).
134
134
  //
135
- // Bootstrap is an OPTIONAL upgrade that BLOCKS the pre-first-paint critical path
136
- // (a second call after getWidgetConfig already succeeded), and we already hold a
137
- // usable getWidgetConfig config to fall back to — so it is tightly bounded. Kept
138
- // low so a slow/hung endpoint adds at most this much to first paint; on timeout
139
- // the fetch aborts and we fall back to getWidgetConfig. CALIBRATE against the
140
- // endpoint's real p99 before ramping the gate (a value below p99 will make normal
141
- // bootstraps false-abort into the fallback, defeating the consolidation).
135
+ // Bootstrap BLOCKS the pre-first-paint critical path, so it is bounded by a
136
+ // timeout: a hung /v1/session/bootstrap (server black-hole, half-open socket,
137
+ // slow-loris body) would otherwise block injection until the browser's
138
+ // multi-minute default. CALIBRATE against the endpoint's real p99 — with no
139
+ // fallback, a value below p99 turns normal-but-slow bootstraps into FAILED
140
+ // injections, so err on the generous side rather than the aggressive one.
142
141
  const BOOTSTRAP_TIMEOUT_MS = 1500;
143
142
 
144
143
  export const fetchBootstrapConfig = async ({
@@ -148,19 +147,43 @@ export const fetchBootstrapConfig = async ({
148
147
  source,
149
148
  namespace,
150
149
  gateNames,
150
+ chatId,
151
151
  url,
152
- orgId,
153
- orgShortName,
154
152
  contextSource,
155
153
  env,
156
- chatId,
157
154
  featureGates,
158
155
  }: FetchBootstrapConfigParams): Promise<BootstrapResult> => {
159
156
  const version = configVersionOverride();
157
+ // The url_resolving branch is requested only when the bundle supplied a URL
158
+ // (Envive enabled). Absent → bootstrap returns config only (envive_on=false).
159
+ const urlResolvingBody =
160
+ url !== undefined
161
+ ? {
162
+ url_resolving: {
163
+ url,
164
+ context: {
165
+ user_id: userId,
166
+ // org_id / org_short_name intentionally omitted — the server fills
167
+ // them from the authenticated org (see SessionBootstrapService).
168
+ chat_id: chatId ?? '',
169
+ source: contextSource,
170
+ env,
171
+ },
172
+ // Override-only gates (empty unless the client set an override); the
173
+ // server merges these OVER its own resolved gates (pymono
174
+ // SessionBootstrapUrlResolvingRequest.feature_gates), so client-side
175
+ // overrides still reach page-variant selection.
176
+ feature_gates: featureGates,
177
+ // The config-version override comes ONLY from the URL query param
178
+ // (configVersionOverride) — the same source the config version uses.
179
+ override_config_version: version,
180
+ },
181
+ }
182
+ : {};
160
183
 
161
184
  // Armed before fetch and kept live across response.json() (a stalled body read
162
185
  // must also be interruptible), cleared in finally. On timeout the fetch rejects
163
- // with an AbortError → maybeUpgradeToBootstrap's catch falls back to getWidgetConfig.
186
+ // with an AbortError → propagates through getEnviveConfig and aborts injection.
164
187
  const controller = new AbortController();
165
188
  const timeoutId = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS);
166
189
  let result: RawBootstrapResponse;
@@ -179,28 +202,7 @@ export const fetchBootstrapConfig = async ({
179
202
  namespace,
180
203
  include_feature_gates: gateNames,
181
204
  version,
182
- url_resolving: {
183
- url,
184
- context: {
185
- user_id: userId,
186
- org_id: orgId,
187
- org_short_name: orgShortName,
188
- chat_id: chatId ?? '',
189
- source: contextSource,
190
- env,
191
- },
192
- // Override-aware gates so client gate overrides reach page-variant
193
- // resolution (bootstrap merges these over its server-resolved gates).
194
- // Ignored by pymono until the endpoint gains the field — safe to ship early.
195
- feature_gates: featureGates,
196
- // The config-version override comes ONLY from the URL query param
197
- // (configVersionOverride) — the same source getWidgetConfig uses. It must
198
- // NOT come from getWidgetConfig's resolved version: on the gate-ON path
199
- // that response is discarded, and bootstrap resolves the config version
200
- // server-side (scoping url_resolving to that resolved version, if desired,
201
- // belongs on the server — not a getWidgetConfig dependency here).
202
- override_config_version: version,
203
- },
205
+ ...urlResolvingBody,
204
206
  }),
205
207
  signal: controller.signal,
206
208
  });
@@ -213,16 +215,22 @@ export const fetchBootstrapConfig = async ({
213
215
  clearTimeout(timeoutId);
214
216
  }
215
217
 
216
- // A 2xx with a missing/partial payload must NOT silently replace the known-good
217
- // getWidgetConfig config with empty gates / mock products — that would disable the
218
- // client session or drop experiments. Throw instead, so maybeUpgradeToBootstrap's
219
- // catch falls back to the getWidgetConfig result it already holds. `gates` is
220
- // required as a NON-EMPTY array: we always request the full gateNames list and
221
- // only reach bootstrap because getWidgetConfig returned real gates, so an
222
- // empty/absent gates response is degraded → fall back. (A products config that is
223
- // present-but-malformed still degrades to mock below — only the top-level
224
- // sections are required here.)
225
- if (!result.org_config?.org?.org || !result.org_config?.gates?.length || !result.widget_config) {
218
+ // A 2xx with a missing/partial payload must NOT silently become a broken config
219
+ // — a missing org identity or widget_config would drop products/experiments.
220
+ // Throw instead: with bootstrap the sole config call, the throw propagates to
221
+ // inject.ts and aborts injection rather than rendering a broken session. `gates`
222
+ // is required to be an ARRAY (present) but MAY be empty: an empty gates response
223
+ // is a valid "everything off" signal — FeatureFlagService then resolves every
224
+ // gate to false, so isClientSessionEnabled is false and the session disables
225
+ // gracefully (with the "Envive Initialized" event still firing). Rejecting empty
226
+ // gates would instead abort injection and lose that event. (A products config
227
+ // that is present-but-malformed still degrades to mock below — only the
228
+ // top-level sections are required here.)
229
+ if (
230
+ !result.org_config?.org?.org ||
231
+ !Array.isArray(result.org_config?.gates) ||
232
+ !result.widget_config
233
+ ) {
226
234
  throw new Error(
227
235
  'Bootstrap config response missing required sections (org_config.org.org / org_config.gates / widget_config)',
228
236
  );
@@ -11,20 +11,10 @@ import { ColorMappingV3 } from 'src/application/models/colorsConfigV3';
11
11
  // React) through the package's component index into the injection bundle's
12
12
  // pre-config chunk.
13
13
  import type { FloatingButtonLocation } from '@envive-ai/react-toolkit-v3/FloatingButton';
14
- import { getWidgetConfigQuery } from 'src/application/models/graphql/queries/getWidgetConfigQuery';
15
14
  import { mockV3ColorsConfig, mockV3FrontendConfig } from 'src/contexts/graphqlContext/mockV3Config';
16
15
  import { WidgetConfigV3 } from 'src/contexts/typesV3';
17
16
  import { PageVariantConfig, PageVariantTestType, WidgetMountingConfig } from 'src/contexts/types';
18
17
  import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
19
- import { configVersionOverride } from 'src/types/config-versions';
20
- import {
21
- ExperimentConfigResolutionMetadata,
22
- OrgConfigExperimentAssignment,
23
- OrgConfigFeatureGate,
24
- } from 'src/application/models/api/orgConfigResults';
25
- // Type-only import — erased at compile time, so this does not create a runtime
26
- // import cycle with enviveConfigService (which imports fetchUnifiedGraphQLConfig).
27
- import type { EnviveServiceConfig } from './enviveConfigService';
28
18
 
29
19
  const logger = new Logger('fetchGraphQLConfig');
30
20
 
@@ -238,113 +228,3 @@ export const transformV3ProductsConfig = (
238
228
  },
239
229
  };
240
230
  };
241
-
242
- // The camelCase `experimentAssignments` GraphQL shape → the snake_case wire
243
- // shape amplitudeService reads (it previously arrived from REST /v1/org/config).
244
- const toExperimentAssignments = (
245
- raw:
246
- | ReadonlyArray<{
247
- layerName?: string;
248
- namespace?: OrgConfigExperimentAssignment['namespace'];
249
- allocatedExperimentName?: string | null;
250
- groupName?: string | null;
251
- }>
252
- | undefined,
253
- ): OrgConfigExperimentAssignment[] =>
254
- (raw ?? []).map(a => ({
255
- layer_name: a.layerName,
256
- namespace: a.namespace,
257
- allocated_experiment_name: a.allocatedExperimentName,
258
- group_name: a.groupName,
259
- }));
260
-
261
- // The unified `getWidgetConfig` config path — now the single source of session
262
- // config. Returns everything the client needs in one call: org identity,
263
- // feature gates, and experiment assignments (which used to come from REST
264
- // `/v1/org/config`), plus the productsConfig and the resolution metadata pymono
265
- // used to pick the version (callers thread `resolution.baseVersion` into
266
- // downstream inference so it's scoped to the version that produced the rendered
267
- // widgets). `gateNames` selects which gates to evaluate; `source` feeds Statsig
268
- // user properties. A `spiffy_config_version` / `envive_config_version` URL
269
- // param, when present, is threaded through as the `version` variable to pin a
270
- // specific version (bypasses the merchant experiment — QA / rollback).
271
- //
272
- // Throws on network/GraphQL failure: with org + gates sourced here, there is no
273
- // partial-render fallback (inject.ts aborts injection on the throw), matching
274
- // how the old /v1/org/config call behaved.
275
- export const fetchUnifiedGraphQLConfig = async (
276
- baseUrl: string,
277
- apiKey: string,
278
- userId: string,
279
- gateNames: string[],
280
- source: string,
281
- ): Promise<EnviveServiceConfig> => {
282
- const query = getWidgetConfigQuery();
283
- const version = configVersionOverride();
284
- const response = await fetch(`${baseUrl}/v1/graphql`, {
285
- method: 'POST',
286
- headers: {
287
- 'Content-Type': 'application/json',
288
- Authorization: `Bearer ${apiKey}`,
289
- },
290
- body: JSON.stringify({ query, variables: { userId, version, gateNames, source } }),
291
- });
292
-
293
- if (!response.ok) {
294
- throw new Error(`Unified config request failed: ${response.statusText}`);
295
- }
296
-
297
- const result = await response.json();
298
- if (result.errors) {
299
- throw new Error(`Unified config GraphQL errors: ${JSON.stringify(result.errors)}`);
300
- }
301
-
302
- const me = result.data?.me;
303
- const widgetConfig = me?.getWidgetConfig;
304
- const org = me?.org;
305
-
306
- // A malformed products config (e.g. an invalid floating-button position)
307
- // degrades to no config — widgets fall back to their mock defaults — WITHOUT
308
- // losing the org / gates / experiments that came back fine. The fetch itself
309
- // still throws above; only the products-config transform is guarded here.
310
- const resolution = widgetConfig?.resolution as ExperimentConfigResolutionMetadata | undefined;
311
- let orgConfig: GraphQlConfigValues;
312
- try {
313
- orgConfig = { ...transformV3ProductsConfig(widgetConfig?.productsConfig), resolution };
314
- } catch (err) {
315
- logger.logError('fetchUnifiedGraphQLConfig | Error transforming products config', err);
316
- orgConfig = { colorsConfig: undefined, frontendConfig: undefined, resolution };
317
- }
318
-
319
- return {
320
- // GraphQL Organization is camelCase; the rest of the app reads the
321
- // snake_case shape /v1/org/config used to return. `status` isn't exposed by
322
- // the GraphQL type (and isn't consumed downstream) — default it.
323
- org: {
324
- org: {
325
- id: org?.id ?? '',
326
- short_name: org?.shortName ?? '',
327
- display_name: org?.displayName ?? '',
328
- domain: org?.domain ?? '',
329
- status: '',
330
- created_at: org?.createdAt ?? '',
331
- updated_at: org?.updatedAt ?? '',
332
- },
333
- },
334
- gates: (
335
- (widgetConfig?.gates ?? []) as Array<{
336
- name?: string;
337
- value?: boolean;
338
- groupName?: string;
339
- }>
340
- ).map(
341
- (g): OrgConfigFeatureGate => ({
342
- name: g.name,
343
- value: g.value,
344
- groupName: g.groupName,
345
- }),
346
- ),
347
- experiment_assignments: toExperimentAssignments(widgetConfig?.experimentAssignments),
348
- orgConfig,
349
- };
350
- };
@@ -0,0 +1,54 @@
1
+ import { FeatureFlagService } from '../index';
2
+
3
+ // getFeatureFlagOverrides feeds /v1/session/bootstrap's url_resolving.feature_gates.
4
+ // It must return ONLY the gates the client actually overrides — never a full
5
+ // resolved map — so the server's own resolved gates survive the merge (the client
6
+ // on the bootstrap-only path has no server values to resolve from).
7
+ describe('FeatureFlagService.getFeatureFlagOverrides', () => {
8
+ const _envive = window as Window & { _envive?: { featureOverrides?: Record<string, boolean> } };
9
+
10
+ beforeEach(() => {
11
+ window.history.replaceState({}, '', '/');
12
+ window.localStorage.clear();
13
+ delete _envive._envive;
14
+ });
15
+
16
+ afterEach(() => {
17
+ window.history.replaceState({}, '', '/');
18
+ window.localStorage.clear();
19
+ delete _envive._envive;
20
+ });
21
+
22
+ it('returns an empty map when nothing is overridden', () => {
23
+ expect(FeatureFlagService.getFeatureFlagOverrides()).toEqual({});
24
+ });
25
+
26
+ it('includes only the gates overridden via query param', () => {
27
+ window.history.replaceState({}, '', '/?use_unified_config=true&is_empty_div_plp_enabled=false');
28
+
29
+ const overrides = FeatureFlagService.getFeatureFlagOverrides();
30
+
31
+ // Only the two overridden gates appear — un-overridden gates are absent
32
+ // entirely (NOT resolved to false), so the server's gates survive the merge.
33
+ expect(overrides).toEqual({ use_unified_config: true, is_empty_div_plp_enabled: false });
34
+ });
35
+
36
+ it('includes a gate overridden via localStorage', () => {
37
+ window.localStorage.setItem(
38
+ 'spiffy-feature-flags',
39
+ JSON.stringify({ use_unified_config: true }),
40
+ );
41
+
42
+ expect(FeatureFlagService.getFeatureFlagOverrides()).toEqual({ use_unified_config: true });
43
+ });
44
+
45
+ it('lets a query param override win over localStorage for the same gate', () => {
46
+ window.localStorage.setItem(
47
+ 'spiffy-feature-flags',
48
+ JSON.stringify({ use_unified_config: false }),
49
+ );
50
+ window.history.replaceState({}, '', '/?use_unified_config=true');
51
+
52
+ expect(FeatureFlagService.getFeatureFlagOverrides().use_unified_config).toBe(true);
53
+ });
54
+ });
@@ -160,17 +160,30 @@ export class FeatureFlagService {
160
160
  : {};
161
161
  };
162
162
 
163
- static persistFeatureGateOverrides(): void {
164
- if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
165
- return;
166
- }
167
- const overrides = Object.values(FeatureGates)
163
+ // Only the gates the client actually overrides (query param / window / stored),
164
+ // with the un-overridden ones left out entirely — NOT resolved to false. This
165
+ // is what /v1/session/bootstrap's url_resolving wants: the server resolves the
166
+ // real gate values itself and merges this map OVER them, so a full map of
167
+ // resolved values would clobber the server's gates with the client's (which,
168
+ // on the bootstrap-only path, has no server values to resolve from). An empty
169
+ // map (the common case — no overrides) leaves the server's gates untouched.
170
+ static getFeatureFlagOverrides(): Record<string, boolean> {
171
+ return Object.values(FeatureGates)
168
172
  .map(
169
173
  featureGate =>
170
174
  [featureGate, FeatureFlagService.getFeatureFlagOverride(featureGate)] as const,
171
175
  )
172
176
  .filter(([, value]) => value !== undefined)
173
177
  .reduce<Record<string, boolean>>((acc, [key, value]) => ({ ...acc, [key]: value! }), {});
174
- window.localStorage.setItem(FEATURE_FLAGS_STORAGE_KEY, JSON.stringify(overrides));
178
+ }
179
+
180
+ static persistFeatureGateOverrides(): void {
181
+ if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
182
+ return;
183
+ }
184
+ window.localStorage.setItem(
185
+ FEATURE_FLAGS_STORAGE_KEY,
186
+ JSON.stringify(FeatureFlagService.getFeatureFlagOverrides()),
187
+ );
175
188
  }
176
189
  }
@@ -156,4 +156,9 @@ export const GA4_EVENT_SCHEMA: Record<EnviveMetricsEventName, GA4EventSchemaEntr
156
156
  [EnviveMetricsEventName.WidgetTextClicked]: {
157
157
  gaEventName: null,
158
158
  },
159
+
160
+ // Internal eng operational monitoring — never projected to merchant GA4.
161
+ [EnviveMetricsEventName.Diagnostics]: {
162
+ gaEventName: null,
163
+ },
159
164
  };